Merge remote-tracking branch 'upstream/main' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2026-01-27 09:44:55 -05:00
281 changed files with 52773 additions and 20381 deletions

75
.agents/commands/review-branch Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env nu
# A command to review the changes made in the current Git branch.
#
# IMPORTANT: This command is prompted to NOT write any code and to ONLY
# produce a review summary. You should still be vigilant when running this
# but that is the expected behavior.
#
# The optional `<issue>` parameter can be an issue number, PR number,
# or a full GitHub URL to provide additional context.
def main [
issue?: any, # Optional GitHub issue/PR number or URL for context
] {
let issueContext = if $issue != null {
let data = gh issue view $issue --json author,title,number,body,comments | from json
let comments = if ($data.comments? != null) {
$data.comments | each { |comment|
let author = if ($comment.author?.login? != null) { $comment.author.login } else { "unknown" }
$"
### Comment by ($author)
($comment.body)
" | str trim
} | str join "\n\n"
} else {
""
}
$"
## Source Issue: ($data.title) \(#($data.number)\)
### Description
($data.body)
### Comments
($comments)
"
} else {
""
}
$"
# Branch Review
Inspect the changes made in this Git branch. Identify any possible issues
and suggest improvements. Do not write code. Explain the problems clearly
and propose a brief plan for addressing them.
($issueContext)
## Your Tasks
You are an experienced software developer with expertise in code review.
Review the change history between the current branch and its
base branch. Analyze all relevant code for possible issues, including but
not limited to:
- Code quality and readability
- Code style that matches or mimics the rest of the codebase
- Potential bugs or logical errors
- Edge cases that may not be handled
- Performance considerations
- Security vulnerabilities
- Backwards compatibility \(if applicable\)
- Test coverage and effectiveness
For test coverage, consider if the changes are in an area of the codebase
that is testable. If so, check if there are appropriate tests added or
modified. Consider if the code itself should be modified to be more
testable.
Think deeply about the implications of the changes here and proposed.
Consult the oracle if you have access to it.
**ONLY CREATE A SUMMARY. DO NOT WRITE ANY CODE.**
" | str trim
}

View File

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

2
.gitattributes vendored
View File

@@ -4,11 +4,11 @@ build.zig.zon.json linguist-generated=true
vendor/** linguist-vendored
website/** linguist-documentation
pkg/breakpad/vendor/** linguist-vendored
pkg/cimgui/vendor/** linguist-vendored
pkg/glfw/wayland-headers/** linguist-vendored
pkg/libintl/config.h linguist-generated=true
pkg/libintl/libintl.h linguist-generated=true
pkg/simdutf/vendor/** linguist-vendored
src/font/nerd_font_attributes.zig linguist-generated=true
src/font/nerd_font_codepoint_tables.py linguist-generated=true
src/font/res/** linguist-vendored
src/terminal/res/** linguist-vendored

50
.github/workflows/flatpak.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
on:
workflow_dispatch:
inputs:
source-run-id:
description: run id of the workflow that generated the artifact
required: true
type: string
source-artifact-id:
description: source tarball built during build-dist
required: true
type: string
name: Flatpak
jobs:
build:
if: github.repository == 'ghostty-org/ghostty'
name: "Flatpak"
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
options: --privileged
strategy:
fail-fast: false
matrix:
variant:
- arch: x86_64
runner: namespace-profile-ghostty-md
- arch: aarch64
runner: namespace-profile-ghostty-md-arm64
runs-on: ${{ matrix.variant.runner }}
steps:
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
run-id: ${{ inputs.source-run-id }}
artifact-ids: ${{ inputs.source-artifact-id }}
github-token: ${{ github.token }}
- name: Extract tarball
run: |
mkdir dist
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6
with:
bundle: com.mitchellh.ghostty
manifest-path: dist/flatpak/com.mitchellh.ghostty.yml
cache-key: flatpak-builder-${{ github.sha }}
arch: ${{ matrix.variant.arch }}
verbose: true

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ jobs:
fi
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -80,7 +80,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -89,11 +89,11 @@ jobs:
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -113,7 +113,7 @@ jobs:
nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password
- name: Upload artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: source-tarball
path: |-
@@ -132,18 +132,18 @@ jobs:
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -269,7 +269,7 @@ jobs:
zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/
- name: Upload artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: macos
path: |-
@@ -286,7 +286,7 @@ jobs:
curl -sL https://sentry.io/get-cli/ | bash
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: macos
@@ -306,10 +306,10 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: macos
@@ -340,7 +340,7 @@ jobs:
mv appcast_new.xml appcast.xml
- name: Upload artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sparkle
path: |-
@@ -357,17 +357,17 @@ jobs:
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
steps:
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: macos
- name: Download Sparkle Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: sparkle
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: source-tarball

View File

@@ -29,14 +29,14 @@ jobs:
commit: ${{ steps.extract_build_info.outputs.commit }}
commit_long: ${{ steps.extract_build_info.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -66,7 +66,7 @@ jobs:
needs: [setup, build-macos]
if: needs.setup.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Tip Tag
run: |
git config user.name "github-actions[bot]"
@@ -81,7 +81,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -104,7 +104,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -127,7 +127,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install sentry-cli
run: |
@@ -159,17 +159,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -217,7 +217,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -226,13 +226,13 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -451,7 +451,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -460,13 +460,13 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -635,7 +635,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Important so that build number generation works
fetch-depth: 0
@@ -644,13 +644,13 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version

View File

@@ -26,7 +26,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
run-id: ${{ inputs.source-run-id }}
artifact-ids: ${{ inputs.source-artifact-id }}

View File

@@ -24,7 +24,7 @@ jobs:
- build-linux-libghostty
- build-nix
- build-macos
- build-macos-matrix
- build-macos-freetype
- build-snap
- build-windows
- test
@@ -44,7 +44,7 @@ jobs:
- test-debian-13
- valgrind
- zig-fmt
- flatpak
steps:
- id: status
name: Determine status
@@ -74,7 +74,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -84,10 +84,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -117,7 +117,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -127,10 +127,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -150,7 +150,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -160,10 +160,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -184,7 +184,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -194,10 +194,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -217,6 +217,7 @@ jobs:
x86_64-macos,
aarch64-linux,
x86_64-linux,
x86_64-linux-musl,
x86_64-windows,
wasm32-freestanding,
]
@@ -227,7 +228,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -237,10 +238,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -263,7 +264,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -273,10 +274,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -292,7 +293,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -302,10 +303,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -325,7 +326,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -335,10 +336,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -371,7 +372,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -381,10 +382,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -397,7 +398,7 @@ jobs:
- name: Upload artifact
id: upload-artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: source-tarball
path: |-
@@ -409,7 +410,7 @@ jobs:
needs: [build-dist, build-snap]
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trigger Snap workflow
run: |
@@ -421,24 +422,42 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
trigger-flatpak:
if: github.event_name != 'pull_request'
runs-on: namespace-profile-ghostty-xsm
needs: [build-dist, build-flatpak]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trigger Flatpak workflow
run: |
gh workflow run \
flatpak.yml \
--ref ${{ github.ref_name || 'main' }} \
--field source-run-id=${{ github.run_id }} \
--field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-macos:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -464,24 +483,24 @@ jobs:
cd macos
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
build-macos-matrix:
build-macos-freetype:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -493,18 +512,10 @@ jobs:
- name: Test All
run: |
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape
- name: Build All
run: |
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape
build-windows:
runs-on: windows-2022
@@ -514,7 +525,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# This could be from a script if we wanted to but inlining here for now
# in one place.
@@ -585,7 +596,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get required Zig version
id: zig
@@ -600,10 +611,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -632,7 +643,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -642,10 +653,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -680,7 +691,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -690,10 +701,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -715,7 +726,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -725,10 +736,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -742,19 +753,19 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Xcode Select
run: sudo xcode-select -s /Applications/Xcode_26.0.app
run: sudo xcode-select -s /Applications/Xcode_26.2.app
- name: Xcode Version
run: xcodebuild -version
@@ -779,7 +790,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -789,10 +800,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -809,17 +820,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -833,21 +844,23 @@ jobs:
if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
timeout-minutes: 60
permissions:
contents: read
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -855,6 +868,8 @@ jobs:
useDaemon: false # sometimes fails on short jobs
- name: pinact check
run: nix develop -c pinact run --check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prettier:
if: github.repository == 'ghostty-org/ghostty'
@@ -864,17 +879,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -891,17 +906,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -918,17 +933,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -945,17 +960,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -967,8 +982,6 @@ jobs:
--check-sourced \
--color=always \
--severity=warning \
--shell=bash \
--external-sources \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
translations:
@@ -979,17 +992,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1006,17 +1019,17 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1040,7 +1053,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -1050,10 +1063,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1072,10 +1085,10 @@ jobs:
uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10
- name: Configure Namespace powered Buildx
uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20
uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: source-tarball
@@ -1092,32 +1105,6 @@ jobs:
build-args: |
DISTRO_VERSION=13
flatpak:
if: github.repository == 'ghostty-org/ghostty'
name: "Flatpak"
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
options: --privileged
strategy:
fail-fast: false
matrix:
variant:
- arch: x86_64
runner: namespace-profile-ghostty-md
- arch: aarch64
runner: namespace-profile-ghostty-md-arm64
runs-on: ${{ matrix.variant.runner }}
needs: test
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6
with:
bundle: com.mitchellh.ghostty
manifest-path: flatpak/com.mitchellh.ghostty.yml
cache-key: flatpak-builder-${{ github.sha }}
arch: ${{ matrix.variant.arch }}
verbose: true
valgrind:
if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-lg
@@ -1128,7 +1115,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@@ -1138,10 +1125,10 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@@ -1167,7 +1154,7 @@ jobs:
# timeout-minutes: 10
# steps:
# - name: Checkout Ghostty
# uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
#
# - name: Start SSH
# run: |

View File

@@ -17,7 +17,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -29,24 +29,41 @@ jobs:
/zig
- name: Setup Nix
uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Run zig fetch
id: zig_fetch
- name: Download colorschemes
id: download
env:
GH_TOKEN: ${{ github.token }}
run: |
# Get the latest release from iTerm2-Color-Schemes
RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest)
TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name')
nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz"
FILENAME="ghostty-themes-${TAG_NAME}.tgz"
mkdir -p upload
curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz"
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
echo "filename=$FILENAME" >> $GITHUB_OUTPUT
- name: Upload to R2
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
with:
r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }}
r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }}
r2-bucket: ghostty-deps
source-dir: upload
destination-dir: ./
- name: Run zig fetch
run: |
nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}"
- name: Update zig cache hash
run: |
@@ -62,7 +79,7 @@ jobs:
run: nix build .#ghostty
- name: Create pull request
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
title: Update iTerm2 colorschemes
base: main
@@ -75,5 +92,5 @@ jobs:
build.zig.zon.json
flatpak/zig-packages.json
body: |
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }}
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }}
labels: dependencies

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ zig-cache/
.zig-cache/
zig-out/
/result*
/.nixos-test-history
example/*.wasm
test/ghostty
test/cases/**/*.actual.png

8
.shellcheckrc Normal file
View File

@@ -0,0 +1,8 @@
# ShellCheck <https://www.shellcheck.net/>
# https://github.com/koalaman/shellcheck/wiki/Directive#shellcheckrc-file
# Allow opening any 'source'd file, even if not specified as input
external-sources=true
# Assume bash by default
shell=bash

View File

@@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/).
- Do not use `xcodebuild`
- Use `zig build` to build the macOS app and any shared Zig code
- Use `zig build run` to build and run the macOS app
- Run Xcode tests using `zig build test`

69
AI_POLICY.md Normal file
View File

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

View File

@@ -13,61 +13,10 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
> time to fixing bugs, maintaining features, and reviewing code, I do kindly
> ask you spend a few minutes reading this document. Thank you. ❤️
## AI Assistance Notice
## AI Usage
> [!IMPORTANT]
>
> If you are using **any kind of AI assistance** to contribute to Ghostty,
> it must be disclosed in the pull request.
If you are using any kind of AI assistance while contributing to Ghostty,
**this must be disclosed in the pull request**, along with the extent to
which AI assistance was used (e.g. docs only vs. code generation).
As a small exception, trivial tab-completion doesn't need to be disclosed,
so long as it is limited to single keywords or short phrases.
The submitter must have also tested the pull request on all impacted
platforms, and it's **highly discouraged** to code for an unfamiliar platform
with AI assistance alone: if you only have a macOS machine, do **not** ask AI
to write the equivalent GTK code, and vice versa — someone else with more
expertise will eventually get to it and do it for you.
Even though using AI to generate responses on a PR is allowed when properly
disclosed, **we do not encourage you to do so**. Often, the positive impact
of genuine, responsive human interaction more than makes up for any language
barrier. ❤️
An example disclosure:
> This PR was written primarily by Claude Code.
Or a more detailed disclosure:
> I consulted ChatGPT to understand the codebase but the solution
> was fully authored manually by myself.
An example of a **problematic** disclosure (not having tested all platforms):
> I used Amp to code both macOS and GTK UIs, but I have not yet tested
> the GTK UI as I don't have a Linux setup.
Failure to disclose this is first and foremost rude to the human operators
on the other end of the pull request, but it also makes it difficult to
determine how much scrutiny to apply to the contribution.
In a perfect world, AI assistance would produce equal or higher quality
work than any human. That isn't the world we live in today, and in most cases
it's generating slop. I say this despite being a fan of and using them
successfully myself (with heavy supervision)!
When using AI assistance, we expect a fairly high level of accountability
and responsibility from contributors, and expect them to understand the code
that is produced and be able to answer critical questions about it. It
isn't a maintainers job to review a PR so broken that it requires
significant rework to be acceptable, and we **reserve the right to close
these PRs without hesitation**.
Please be respectful to maintainers and disclose AI assistance.
The Ghostty project has strict rules for AI usage. Please see
the [AI Usage Policy](AI_POLICY.md). **This is very important.**
## Quick Guide
@@ -202,3 +151,266 @@ pull request will be accepted with a high degree of certainty.
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.
# Developer Guide
> [!NOTE]
>
> **The remainder of this file is dedicated to developers actively
> working on Ghostty.** If you're a user reporting an issue, you can
> ignore the rest of this document.
## Including and Updating Translations
See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details.
## Checking for Memory Leaks
While Zig does an amazing job of finding and preventing memory leaks,
Ghostty uses many third-party libraries that are written in C. Improper usage
of those libraries or bugs in those libraries can cause memory leaks that
Zig cannot detect by itself.
### On Linux
On Linux the recommended tool to check for memory leaks is Valgrind. The
recommended way to run Valgrind is via `zig build`:
```sh
zig build run-valgrind
```
This builds a Ghostty executable with Valgrind support and runs Valgrind
with the proper flags to ensure we're suppressing known false positives.
You can combine the same build args with `run-valgrind` that you can with
`run`, such as specifying additional configurations after a trailing `--`.
## Input Stack Testing
The input stack is the part of the codebase that starts with a
key event and ends with text encoding being sent to the pty (it
does not include _rendering_ the text, which is part of the
font or rendering stack).
If you modify any part of the input stack, you must manually verify
all the following input cases work properly. We unfortunately do
not automate this in any way, but if we can do that one day that'd
save a LOT of grief and time.
Note: this list may not be exhaustive, I'm still working on it.
### Linux IME
IME (Input Method Editors) are a common source of bugs in the input stack,
especially on Linux since there are multiple different IME systems
interacting with different windowing systems and application frameworks
all written by different organizations.
The following matrix should be tested to ensure that all IME input works
properly:
1. Wayland, X11
2. ibus, fcitx, none
3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex
4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors)
> [!NOTE]
>
> This is a **work in progress**. I'm still working on this list and it
> is not complete. As I find more test cases, I will add them here.
#### Dead Key Input
Set your keyboard layout to "Spanish" (or another layout that uses dead keys).
1. Launch Ghostty
2. Press `'`
3. Press `a`
4. Verify that `á` is displayed
Note that the dead key may or may not show a preedit state visually.
For ibus and fcitx it does but for the "none" case it does not. Importantly,
the text should be correct when it is sent to the pty.
We should also test canceling dead key input:
1. Launch Ghostty
2. Press `'`
3. Press escape
4. Press `a`
5. Verify that `a` is displayed (no diacritic)
#### CJK Input
Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The
exact layout doesn't matter.
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Enter`
5. Verify that `こん` is displayed in the terminal.
We should also test switching input methods while preedit is active, which
should commit the text:
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Ctrl+Shift` to switch to another layout (any)
5. Verify that `こん` is displayed in the terminal as committed text.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. They should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.
## Nix VM Integration Tests
Several Nix VM tests are provided by the project for testing Ghostty in a "live"
environment rather than just unit tests.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#check.<system>.<test-name>.driver`. `<system>` should be
`x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux
VM, not a macOS one). `<test-name>` should be one of the tests defined in
`nix/tests.nix`. The test will build and then launch. Depending on the speed
of your system, this can take a while. Eventually though the test should
complete. Hopefully successfully, but if not error messages should be printed
out that can be used to diagnose the issue.
3. To run _all_ of the tests, run `nix flake check`.
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a test.
### Interactively Running Test VMs
To run a test interactively, run `nix run
.#check.<system>.<test-name>.driverInteractive`. This will load a Python console
that can be used to manage the test VMs. In this console run `start_all()` to
start the VM(s). The VMs should boot up and a window should appear showing the
VM's console.
For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos)
### SSH Access to Test VMs
Some test VMs are configured to allow outside SSH access for debugging. To
access the VM, use a command like the following:
```
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1
```
The SSH options are important because the SSH host keys will be regenerated
every time the test is started. Without them, your personal SSH known hosts file
will become difficult to manage. The port that is needed to access the VM may
change depending on the test.
None of the users in the VM have passwords so do not expose these VMs to the Internet.

View File

@@ -93,6 +93,36 @@ produced.
> may ask you to fix it and close the issue. It isn't a maintainers job to
> review a PR so broken that it requires significant rework to be acceptable.
## Logging
Ghostty can write logs to a number of destinations. On all platforms, logging to
`stderr` is available. Depending on the platform and how Ghostty was launched,
logs sent to `stderr` may be stored by the system and made available for later
retrieval.
On Linux if Ghostty is launched by the default `systemd` user service, you can use
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
On macOS logging to the macOS unified log is available and enabled by default.
Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
Ghostty's logging can be configured in two ways. The first is by what
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
any other optimization the debug logs will not be output to `stderr`.
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
to control which destinations receive logs. Ghostty currently defines two
destinations:
- `stderr` - logging to `stderr`.
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
## Linting
### Prettier
@@ -134,6 +164,28 @@ alejandra .
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
### ShellCheck
Bash scripts are checked with [ShellCheck](https://www.shellcheck.net/) in CI.
Nix users can use the following command to run ShellCheck over all of our scripts:
```
nix develop -c shellcheck \
--check-sourced \
--severity=warning \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
```
Non-Nix users can [install ShellCheck](https://github.com/koalaman/shellcheck#user-content-installing) and then run:
```
shellcheck \
--check-sourced \
--severity=warning \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
```
### Updating the Zig Cache Fixed-Output Derivation Hash
The Nix package depends on a [fixed-output

View File

@@ -2,6 +2,7 @@ const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const buildpkg = @import("src/build/main.zig");
const appVersion = @import("build.zig.zon").version;
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;

View File

@@ -15,14 +15,14 @@
},
.vaxis = .{
// rockorager/libvaxis
.url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
.url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
.hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
.lazy = true,
},
.z2d = .{
// vancluever/z2d
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
.lazy = true,
},
.zig_objc = .{
@@ -39,7 +39,7 @@
},
.uucode = .{
// jacobsandlund/uucode
.url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
.url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
.hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
},
.zig_wayland = .{
@@ -50,20 +50,20 @@
},
.zf = .{
// natecraddock/zf
.url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
.url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
.hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
.lazy = true,
},
.gobject = .{
// https://github.com/jcollie/ghostty-gobject based on zig_gobject
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
// Temporary until we generate them at build time automatically.
.url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
.url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
.lazy = true,
},
// C libs
.cimgui = .{ .path = "./pkg/cimgui", .lazy = true },
.dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true },
.fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true },
.freetype = .{ .path = "./pkg/freetype", .lazy = true },
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true },
@@ -116,8 +116,8 @@
// 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-",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
.hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
.lazy = true,
},
},

124
build.zig.zon.bak Normal file
View File

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

35
build.zig.zon.json generated
View File

@@ -1,4 +1,9 @@
{
"N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": {
"name": "bindings",
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
"hash": "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM="
},
"N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ": {
"name": "breakpad",
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
@@ -24,10 +29,10 @@
"url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
"hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U="
},
"gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": {
"gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": {
"name": "gobject",
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
"hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="
"url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
"hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
"name": "gtk4_layer_shell",
@@ -44,15 +49,15 @@
"url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz",
"hash": "sha256-h9T4iT704I8iSXNgj/6/lCaKgTgLp5wS6IQZaMgKohI="
},
"N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3": {
"N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI": {
"name": "imgui",
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
"N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": {
"name": "iterm2_themes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",
@@ -116,12 +121,12 @@
},
"uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": {
"name": "uucode",
"url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
"url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
"hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="
},
"vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": {
"name": "vaxis",
"url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
"url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
"hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="
},
"N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": {
@@ -139,14 +144,14 @@
"url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz",
"hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM="
},
"z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": {
"z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": {
"name": "z2d",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
"hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
"hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="
},
"zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": {
"name": "zf",
"url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="
},
"zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": {

38
build.zig.zon.nix generated
View File

@@ -82,6 +82,14 @@
fetcher.${proto};
in
linkFarm name [
{
name = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr";
path = fetchZigArtifact {
name = "bindings";
url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz";
hash = "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM=";
};
}
{
name = "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ";
path = fetchZigArtifact {
@@ -123,11 +131,11 @@ in
};
}
{
name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV";
name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-";
path = fetchZigArtifact {
name = "gobject";
url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst";
hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=";
url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst";
hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=";
};
}
{
@@ -155,19 +163,19 @@ in
};
}
{
name = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3";
name = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI";
path = fetchZigArtifact {
name = "imgui";
url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz";
hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=";
url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz";
hash = "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=";
};
}
{
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz";
hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=";
};
}
{
@@ -270,7 +278,7 @@ in
name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E";
path = fetchZigArtifact {
name = "uucode";
url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz";
url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz";
hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=";
};
}
@@ -278,7 +286,7 @@ in
name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS";
path = fetchZigArtifact {
name = "vaxis";
url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz";
url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz";
hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=";
};
}
@@ -307,18 +315,18 @@ in
};
}
{
name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T";
name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ";
path = fetchZigArtifact {
name = "z2d";
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz";
hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=";
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz";
hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=";
};
}
{
name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh";
path = fetchZigArtifact {
name = "zf";
url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz";
url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz";
hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=";
};
}

15
build.zig.zon.txt generated
View File

@@ -1,16 +1,17 @@
git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732
https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz
https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz
https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz
https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz
https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz
https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz
https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz
@@ -20,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-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz
https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
https://deps.files.ghostty.org/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/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz
https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
https://github.com/vancluever/z2d/archive/refs/tags/v0.9.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

@@ -17,81 +17,45 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from os.path import isdir
from gi import require_version
from gi.repository import Nautilus, GObject, Gio, GLib
from gi.repository import Nautilus, GObject, Gio
class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider):
def __init__(self):
super().__init__()
session = Gio.bus_get_sync(Gio.BusType.SESSION, None)
self._systemd = None
# Check if the this system runs under systemd, per sd_booted(3)
if isdir('/run/systemd/system/'):
self._systemd = Gio.DBusProxy.new_sync(session,
Gio.DBusProxyFlags.NONE,
None,
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager", None)
def _open_terminal(self, path):
def open_in_ghostty_activated(_menu, paths):
for path in paths:
cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false']
child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
if self._systemd:
# Move new terminal into a dedicated systemd scope to make systemd
# track the terminal separately; in particular this makes systemd
# keep a separate CPU and memory account for the terminal which in turn
# ensures that oomd doesn't take nautilus down if a process in
# ghostty consumes a lot of memory.
pid = int(child.get_identifier())
props = [("PIDs", GLib.Variant('au', [pid])),
('CollectMode', GLib.Variant('s', 'inactive-or-failed'))]
name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid)
args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, []))
self._systemd.call_sync('StartTransientUnit', args,
Gio.DBusCallFlags.NO_AUTO_START, 500, None)
Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
def _menu_item_activated(self, _menu, paths):
for path in paths:
self._open_terminal(path)
def _make_item(self, name, paths):
def get_paths_to_open(files):
paths = []
for file in files:
location = file.get_location() if file.is_directory() else file.get_parent_location()
path = location.get_path()
if path and path not in paths:
paths.append(path)
if 10 < len(paths):
# Let's not open anything if the user selected a lot of directories,
# to avoid accidentally spamming their desktop with dozends of
# new windows or tabs. Ten is a totally arbitrary limit :)
return []
else:
return paths
def get_items_for_files(name, files):
paths = get_paths_to_open(files)
if paths:
item = Nautilus.MenuItem(name=name, label='Open in Ghostty',
icon='com.mitchellh.ghostty')
item.connect('activate', self._menu_item_activated, paths)
return item
item.connect('activate', open_in_ghostty_activated, paths)
return [item]
else:
return []
def _paths_to_open(self, files):
paths = []
for file in files:
location = file.get_location() if file.is_directory() else file.get_parent_location()
path = location.get_path()
if path and path not in paths:
paths.append(path)
if 10 < len(paths):
# Let's not open anything if the user selected a lot of directories,
# to avoid accidentally spamming their desktop with dozends of
# new windows or tabs. Ten is a totally arbitrary limit :)
return []
else:
return paths
def get_file_items(self, *args):
# Nautilus 3.0 API passes args (window, files), 4.0 API just passes files
files = args[0] if len(args) == 1 else args[1]
paths = self._paths_to_open(files)
if paths:
return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)]
else:
return []
class GhosttyMenuProvider(GObject.GObject, Nautilus.MenuProvider):
def get_file_items(self, files):
return get_items_for_files('GhosttyNautilus::open_in_ghostty', files)
def get_background_items(self, *args):
# Nautilus 3.0 API passes args (window, file), 4.0 API just passes file
file = args[0] if len(args) == 1 else args[1]
paths = self._paths_to_open([file])
if paths:
return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)]
else:
return []
def get_background_items(self, file):
return get_items_for_files('GhosttyNautilus::open_folder_in_ghostty', [file])

66
flake.lock generated
View File

@@ -3,11 +3,11 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
@@ -34,36 +34,44 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1768068402,
"narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=",
"rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469",
"lastModified": 1768032153,
"narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=",
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1758360447,
"narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=",
"rev": "8eaee110344796db060382e15d3af0a9fc396e0e",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs",
"zig": "zig",
"zon2nix": "zon2nix"
@@ -97,11 +105,11 @@
]
},
"locked": {
"lastModified": 1760401936,
"narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=",
"lastModified": 1763295135,
"narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "365085b6652259753b598d43b723858184980bbe",
"rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a",
"type": "github"
},
"original": {
@@ -112,20 +120,22 @@
},
"zon2nix": {
"inputs": {
"nixpkgs": "nixpkgs_2"
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1758405547,
"narHash": "sha256-WgaDgvIZMPvlZcZrpPMjkaalTBnGF2lTG+62znXctWM=",
"lastModified": 1768231828,
"narHash": "sha256-wL/8Iij4T2OLkhHcc4NieOjf7YeJffaUYbCiCqKv/+0=",
"owner": "jcollie",
"repo": "zon2nix",
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
"type": "github"
},
"original": {
"owner": "jcollie",
"repo": "zon2nix",
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
"type": "github"
}
}

186
flake.nix
View File

@@ -6,7 +6,9 @@
# glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for.
#
# We are currently on unstable to get Zig 0.15 for our package.nix
# We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and
# Gnome 49/Gtk 4.20.
#
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
flake-utils.url = "github:numtide/flake-utils";
@@ -26,12 +28,16 @@
};
zon2nix = {
url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245";
url = "github:jcollie/zon2nix?rev=c28e93f3ba133d4c1b1d65224e2eebede61fd071";
inputs = {
# Don't override nixpkgs until Zig 0.15 is available in the Nix branch
# we are using for "normal" builds.
#
# nixpkgs.follows = "nixpkgs";
nixpkgs.follows = "nixpkgs";
};
};
home-manager = {
url = "github:nix-community/home-manager";
inputs = {
nixpkgs.follows = "nixpkgs";
};
};
};
@@ -41,92 +47,102 @@
nixpkgs,
zig,
zon2nix,
home-manager,
...
}:
builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
builtins.map (
system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell.${system} = pkgs.callPackage ./nix/devShell.nix {
zig = zig.packages.${system}."0.15.2";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
}: let
inherit (nixpkgs) lib legacyPackages;
python3 = pkgs.python3.override {
self = pkgs.python3;
packageOverrides = pyfinal: pyprev: {
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
};
};
# Our supported systems are the same supported systems as the Zig binaries.
platforms = lib.attrNames zig.packages;
# It's not always possible to build Ghostty with Nix for each system,
# one such example being macOS due to missing Swift 6 and xcodebuild
# support in the Nix ecosystem. Therefore for things like package outputs
# we need to limit the attributes we expose.
buildablePlatforms = lib.filter (p: !(lib.systems.elaborate p).isDarwin) platforms;
forAllPlatforms = f: lib.genAttrs platforms (s: f legacyPackages.${s});
forBuildablePlatforms = f: lib.genAttrs buildablePlatforms (s: f legacyPackages.${s});
in {
devShell = forAllPlatforms (pkgs:
pkgs.callPackage ./nix/devShell.nix {
zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.15.2";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
python3 = pkgs.python3.override {
self = pkgs.python3;
packageOverrides = pyfinal: pyprev: {
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
};
packages.${system} = let
mkArgs = optimize: {
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
deps = pkgs.callPackage ./build.zig.zon.nix {};
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
};
formatter.${system} = pkgs.alejandra;
apps.${system} = let
runVM = (
module: let
vm = import ./nix/vm/create.nix {
inherit system module nixpkgs;
overlay = self.overlays.debug;
};
program = pkgs.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
}
);
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
x11-gnome = runVM ./nix/vm/x11-gnome.nix;
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
};
}
# Our supported systems are the same supported systems as the Zig binaries.
) (builtins.attrNames zig.packages)
)
// {
overlays = {
default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast;
};
debug = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug;
});
packages =
forAllPlatforms (pkgs: {
# Deps are needed for environmental setup on macOS
deps = pkgs.callPackage ./build.zig.zon.nix {};
})
// forBuildablePlatforms (pkgs: let
mkArgs = optimize: {
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
});
formatter = forAllPlatforms (pkgs: pkgs.alejandra);
apps = forBuildablePlatforms (pkgs: let
runVM = module: desc: let
vm = import ./nix/vm/create.nix {
inherit (pkgs.stdenv.hostPlatform) system;
inherit module nixpkgs;
overlay = self.overlays.debug;
};
program = pkgs.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
meta.description = "start a vm from ${toString module}";
};
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
});
checks = forAllPlatforms (pkgs:
import ./nix/tests.nix {
inherit home-manager nixpkgs self;
inherit (pkgs.stdenv.hostPlatform) system;
});
overlays = {
default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast;
};
debug = final: prev: {
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug;
};
create-vm = import ./nix/vm/create.nix;
create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix;
create-gnome-vm = import ./nix/vm/create-gnome.nix;
create-plasma6-vm = import ./nix/vm/create-plasma6.nix;
create-xfce-vm = import ./nix/vm/create-xfce.nix;
};
};
nixConfig = {
extra-substituters = ["https://ghostty.cachix.org"];

View File

@@ -1,4 +1,10 @@
[
{
"type": "archive",
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
"dest": "vendor/p/N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
"sha256": "8bfec500e00926f679853ee23d67cc392d3c3181733ca4704738651d3f70baa3"
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
@@ -31,9 +37,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
"sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a"
"url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
"dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
"sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48"
},
{
"type": "archive",
@@ -55,15 +61,15 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"dest": "vendor/p/N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
"sha256": "a05fd01e04cf11ab781e28387c621d2e420f1e6044c8e27a25e603ea99ef7860"
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"dest": "vendor/p/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
"sha256": "c816c20e8c75f3e15ae867350e79925502d1a6a85938bb1a73b8927e5f31f9cb"
},
{
"type": "archive",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
"sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5"
},
{
"type": "archive",
@@ -139,13 +145,13 @@
},
{
"type": "archive",
"url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
"url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
"dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
"sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e"
},
{
"type": "archive",
"url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
"url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
"dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
"sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6"
},
@@ -169,13 +175,13 @@
},
{
"type": "archive",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
"dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
"sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10"
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
"dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
"sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7"
},
{
"type": "archive",
"url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
"sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568"
},

View File

@@ -66,6 +66,14 @@ typedef enum {
GHOSTTY_MOUSE_LEFT,
GHOSTTY_MOUSE_RIGHT,
GHOSTTY_MOUSE_MIDDLE,
GHOSTTY_MOUSE_FOUR,
GHOSTTY_MOUSE_FIVE,
GHOSTTY_MOUSE_SIX,
GHOSTTY_MOUSE_SEVEN,
GHOSTTY_MOUSE_EIGHT,
GHOSTTY_MOUSE_NINE,
GHOSTTY_MOUSE_TEN,
GHOSTTY_MOUSE_ELEVEN,
} ghostty_input_mouse_button_e;
typedef enum {
@@ -102,6 +110,13 @@ typedef enum {
GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
} ghostty_input_mods_e;
typedef enum {
GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
} ghostty_binding_flags_e;
typedef enum {
GHOSTTY_ACTION_RELEASE,
GHOSTTY_ACTION_PRESS,
@@ -317,12 +332,14 @@ typedef struct {
typedef enum {
GHOSTTY_TRIGGER_PHYSICAL,
GHOSTTY_TRIGGER_UNICODE,
GHOSTTY_TRIGGER_CATCH_ALL,
} ghostty_input_trigger_tag_e;
typedef union {
ghostty_input_key_e translated;
ghostty_input_key_e physical;
uint32_t unicode;
// catch_all has no payload
} ghostty_input_trigger_key_u;
typedef struct {
@@ -414,6 +431,12 @@ typedef union {
ghostty_platform_ios_s ios;
} ghostty_platform_u;
typedef enum {
GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
GHOSTTY_SURFACE_CONTEXT_TAB = 1,
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
} ghostty_surface_context_e;
typedef struct {
ghostty_platform_e platform_tag;
ghostty_platform_u platform;
@@ -426,6 +449,7 @@ typedef struct {
size_t env_var_count;
const char* initial_input;
bool wait_after_command;
ghostty_surface_context_e context;
} ghostty_surface_config_s;
typedef struct {
@@ -452,6 +476,12 @@ typedef struct {
size_t len;
} ghostty_config_color_list_s;
// config.RepeatableCommand
typedef struct {
const ghostty_command_s* commands;
size_t len;
} ghostty_config_command_list_s;
// config.Palette
typedef struct {
ghostty_config_color_s colors[256];
@@ -512,6 +542,12 @@ typedef enum {
GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e;
// apprt.action.GotoWindow
typedef enum {
GHOSTTY_GOTO_WINDOW_PREVIOUS,
GHOSTTY_GOTO_WINDOW_NEXT,
} ghostty_action_goto_window_e;
// apprt.action.ResizeSplit.Direction
typedef enum {
GHOSTTY_RESIZE_SPLIT_UP,
@@ -573,6 +609,12 @@ typedef enum {
GHOSTTY_QUIT_TIMER_STOP,
} ghostty_action_quit_timer_e;
// apprt.action.Readonly
typedef enum {
GHOSTTY_READONLY_OFF,
GHOSTTY_READONLY_ON,
} ghostty_action_readonly_e;
// apprt.action.DesktopNotification.C
typedef struct {
const char* title;
@@ -584,6 +626,12 @@ typedef struct {
const char* title;
} ghostty_action_set_title_s;
// apprt.action.PromptTitle
typedef enum {
GHOSTTY_PROMPT_TITLE_SURFACE,
GHOSTTY_PROMPT_TITLE_TAB,
} ghostty_action_prompt_title_e;
// apprt.action.Pwd.C
typedef struct {
const char* pwd;
@@ -671,6 +719,27 @@ typedef struct {
ghostty_input_trigger_s trigger;
} ghostty_action_key_sequence_s;
// apprt.action.KeyTable.Tag
typedef enum {
GHOSTTY_KEY_TABLE_ACTIVATE,
GHOSTTY_KEY_TABLE_DEACTIVATE,
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
} ghostty_action_key_table_tag_e;
// apprt.action.KeyTable.CValue
typedef union {
struct {
const char *name;
size_t len;
} activate;
} ghostty_action_key_table_u;
// apprt.action.KeyTable.C
typedef struct {
ghostty_action_key_table_tag_e tag;
ghostty_action_key_table_u value;
} ghostty_action_key_table_s;
// apprt.action.ColorKind
typedef enum {
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
@@ -714,6 +783,7 @@ typedef struct {
typedef enum {
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
} ghostty_action_close_tab_mode_e;
// apprt.surface.Message.ChildExited
@@ -784,9 +854,11 @@ typedef enum {
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
GHOSTTY_ACTION_TOGGLE_VISIBILITY,
GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
GHOSTTY_ACTION_MOVE_TAB,
GHOSTTY_ACTION_GOTO_TAB,
GHOSTTY_ACTION_GOTO_SPLIT,
GHOSTTY_ACTION_GOTO_WINDOW,
GHOSTTY_ACTION_RESIZE_SPLIT,
GHOSTTY_ACTION_EQUALIZE_SPLITS,
GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
@@ -813,6 +885,7 @@ typedef enum {
GHOSTTY_ACTION_FLOAT_WINDOW,
GHOSTTY_ACTION_SECURE_INPUT,
GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_KEY_TABLE,
GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
@@ -830,6 +903,7 @@ typedef enum {
GHOSTTY_ACTION_END_SEARCH,
GHOSTTY_ACTION_SEARCH_TOTAL,
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
} ghostty_action_tag_e;
typedef union {
@@ -838,6 +912,7 @@ typedef union {
ghostty_action_move_tab_s move_tab;
ghostty_action_goto_tab_e goto_tab;
ghostty_action_goto_split_e goto_split;
ghostty_action_goto_window_e goto_window;
ghostty_action_resize_split_s resize_split;
ghostty_action_size_limit_s size_limit;
ghostty_action_initial_size_s initial_size;
@@ -846,6 +921,7 @@ typedef union {
ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_prompt_title_e prompt_title;
ghostty_action_pwd_s pwd;
ghostty_action_mouse_shape_e mouse_shape;
ghostty_action_mouse_visibility_e mouse_visibility;
@@ -855,6 +931,7 @@ typedef union {
ghostty_action_float_window_e float_window;
ghostty_action_secure_input_e secure_input;
ghostty_action_key_sequence_s key_sequence;
ghostty_action_key_table_s key_table;
ghostty_action_color_change_s color_change;
ghostty_action_reload_config_s reload_config;
ghostty_action_config_change_s config_change;
@@ -866,6 +943,7 @@ typedef union {
ghostty_action_start_search_s start_search;
ghostty_action_search_total_s search_total;
ghostty_action_search_selected_s search_selected;
ghostty_action_readonly_e readonly;
} ghostty_action_u;
typedef struct {
@@ -946,6 +1024,7 @@ ghostty_config_t ghostty_config_new();
void ghostty_config_free(ghostty_config_t);
ghostty_config_t ghostty_config_clone(ghostty_config_t);
void ghostty_config_load_cli_args(ghostty_config_t);
void ghostty_config_load_file(ghostty_config_t, const char*);
void ghostty_config_load_default_files(ghostty_config_t);
void ghostty_config_load_recursive_files(ghostty_config_t);
void ghostty_config_finalize(ghostty_config_t);
@@ -979,7 +1058,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t,
void ghostty_surface_free(ghostty_surface_t);
void* ghostty_surface_userdata(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
bool ghostty_surface_process_exited(ghostty_surface_t);
@@ -994,9 +1073,10 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
ghostty_color_scheme_e);
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e);
void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*);
bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
bool ghostty_surface_key_is_binding(ghostty_surface_t,
ghostty_input_key_s,
ghostty_binding_flags_e*);
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
bool ghostty_surface_mouse_captured(ghostty_surface_t);

View File

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

View File

@@ -100,5 +100,20 @@
<false/>
<key>SUPublicEDKey</key>
<string>wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=</string>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.mitchellh.ghosttySurfaceId</string>
<key>UTTypeDescription</key>
<string>Ghostty Surface Identifier</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>

View File

@@ -28,6 +28,13 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
proxyType = 1;
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
remoteInfo = Ghostty;
};
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
@@ -42,6 +49,7 @@
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; };
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>"; };
@@ -66,11 +74,13 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/macOS/AppDelegate.swift,
"App/macOS/AppDelegate+Ghostty.swift",
App/macOS/main.swift,
App/macOS/MainMenu.xib,
Features/About/About.xib,
Features/About/AboutController.swift,
Features/About/AboutView.swift,
Features/About/CyclingIconView.swift,
"Features/App Intents/CloseTerminalIntent.swift",
"Features/App Intents/CommandPaletteIntent.swift",
"Features/App Intents/Entities/CommandEntity.swift",
@@ -95,6 +105,7 @@
Features/QuickTerminal/QuickTerminal.xib,
Features/QuickTerminal/QuickTerminalController.swift,
Features/QuickTerminal/QuickTerminalPosition.swift,
Features/QuickTerminal/QuickTerminalRestorableState.swift,
Features/QuickTerminal/QuickTerminalScreen.swift,
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
Features/QuickTerminal/QuickTerminalSize.swift,
@@ -115,7 +126,9 @@
Features/Terminal/ErrorView.swift,
Features/Terminal/TerminalController.swift,
Features/Terminal/TerminalRestorable.swift,
Features/Terminal/TerminalTabColor.swift,
Features/Terminal/TerminalView.swift,
Features/Terminal/TerminalViewContainer.swift,
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
"Features/Terminal/Window Styles/Terminal.xib",
"Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib",
@@ -135,19 +148,19 @@
Features/Update/UpdateSimulator.swift,
Features/Update/UpdateViewModel.swift,
"Ghostty/FullscreenMode+Extension.swift",
Ghostty/Ghostty.Command.swift,
Ghostty/Ghostty.Error.swift,
Ghostty/Ghostty.Event.swift,
Ghostty/Ghostty.Input.swift,
Ghostty/Ghostty.Surface.swift,
Ghostty/InspectorView.swift,
"Ghostty/NSEvent+Extension.swift",
Ghostty/SurfaceScrollView.swift,
Ghostty/SurfaceView_AppKit.swift,
"Ghostty/Surface View/InspectorView.swift",
"Ghostty/Surface View/SurfaceDragSource.swift",
"Ghostty/Surface View/SurfaceGrabHandle.swift",
"Ghostty/Surface View/SurfaceScrollView.swift",
"Ghostty/Surface View/SurfaceView_AppKit.swift",
Helpers/AppInfo.swift,
Helpers/CodableBridge.swift,
Helpers/Cursor.swift,
Helpers/DraggableWindowView.swift,
Helpers/ExpiringUndoManager.swift,
"Helpers/Extensions/Double+Extension.swift",
"Helpers/Extensions/EventModifiers+Extension.swift",
@@ -155,13 +168,16 @@
"Helpers/Extensions/KeyboardShortcut+Extension.swift",
"Helpers/Extensions/NSAppearance+Extension.swift",
"Helpers/Extensions/NSApplication+Extension.swift",
"Helpers/Extensions/NSColor+Extension.swift",
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/NSMenu+Extension.swift",
"Helpers/Extensions/NSMenuItem+Extension.swift",
"Helpers/Extensions/NSPasteboard+Extension.swift",
"Helpers/Extensions/NSScreen+Extension.swift",
"Helpers/Extensions/NSView+Extension.swift",
"Helpers/Extensions/NSWindow+Extension.swift",
"Helpers/Extensions/NSWorkspace+Extension.swift",
"Helpers/Extensions/Transferable+Extension.swift",
"Helpers/Extensions/UndoManager+Extension.swift",
"Helpers/Extensions/View+Extension.swift",
Helpers/Fullscreen.swift,
@@ -183,18 +199,26 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/iOS/iOSApp.swift,
Ghostty/SurfaceView_UIKit.swift,
"Ghostty/Surface View/SurfaceView_UIKit.swift",
);
target = A5B30530299BEAAA0047F10C /* Ghostty */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* 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>"; };
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
810ACC9C2E9D3301004F8F92 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -251,6 +275,7 @@
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
81F82BC72E82815D001EDFA7 /* Sources */,
A54F45F42E1F047A0046BD5C /* Tests */,
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
A5D495A3299BECBA00DD1313 /* Frameworks */,
A5A1F8862A489D7400D1E8BC /* Resources */,
A5B30532299BEAAA0047F10C /* Products */,
@@ -263,6 +288,7 @@
A5B30531299BEAAA0047F10C /* Ghostty.app */,
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -279,6 +305,29 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */;
buildPhases = (
810ACC9B2E9D3301004F8F92 /* Sources */,
810ACC9C2E9D3301004F8F92 /* Frameworks */,
810ACC9D2E9D3301004F8F92 /* Resources */,
);
buildRules = (
);
dependencies = (
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
);
name = GhosttyUITests;
packageProductDependencies = (
);
productName = GhosttyUITests;
productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
@@ -352,9 +401,13 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1610;
TargetAttributes = {
810ACC9E2E9D3301004F8F92 = {
CreatedOnToolsVersion = 26.1;
TestTargetID = A5B30530299BEAAA0047F10C;
};
A54F45F22E1F047A0046BD5C = {
CreatedOnToolsVersion = 26.0;
TestTargetID = A5B30530299BEAAA0047F10C;
@@ -387,11 +440,19 @@
A5B30530299BEAAA0047F10C /* Ghostty */,
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
810ACC9D2E9D3301004F8F92 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45F12E1F047A0046BD5C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -430,6 +491,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
810ACC9B2E9D3301004F8F92 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A54F45EF2E1F047A0046BD5C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -454,6 +522,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A5B30530299BEAAA0047F10C /* Ghostty */;
targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */;
};
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A5B30530299BEAAA0047F10C /* Ghostty */;
@@ -571,6 +644,73 @@
};
name = ReleaseLocal;
};
810ACCA82E9D3302004F8F92 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = Ghostty;
};
name = Debug;
};
810ACCA92E9D3302004F8F92 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = Ghostty;
};
name = Release;
};
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_TARGET_NAME = Ghostty;
};
name = ReleaseLocal;
};
A54F45F92E1F047A0046BD5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -987,6 +1127,16 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
810ACCA82E9D3302004F8F92 /* Debug */,
810ACCA92E9D3302004F8F92 /* Release */,
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -40,6 +40,17 @@
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "810ACC9E2E9D3301004F8F92"
BuildableName = "GhosttyUITests.xctest"
BlueprintName = "GhosttyUITests"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

View File

@@ -0,0 +1,34 @@
//
// AppKitExtensions.swift
// Ghostty
//
// Created by luca on 27.10.2025.
//
import AppKit
extension NSColor {
var isLightColor: Bool {
return self.luminance > 0.5
}
var luminance: Double {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
return (0.299 * r) + (0.587 * g) + (0.114 * b)
}
}
extension NSImage {
func colorAt(x: Int, y: Int) -> NSColor? {
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
return NSBitmapImageRep(cgImage: cgImage).colorAt(x: x, y: y)
}
}

View File

@@ -0,0 +1,59 @@
//
// GhosttyCustomConfigCase.swift
// Ghostty
//
// Created by luca on 16.10.2025.
//
import XCTest
class GhosttyCustomConfigCase: XCTestCase {
/// We only want run these UI tests
/// when testing manually with Xcode IDE
///
/// So that we don't have to wait for each ci check
/// to run these tedious tests
override class var defaultTestSuite: XCTestSuite {
// https://lldb.llvm.org/cpp_reference/PlatformDarwin_8cpp_source.html#:~:text==%20%22-,IDE_DISABLED_OS_ACTIVITY_DT_MODE
if ProcessInfo.processInfo.environment["IDE_DISABLED_OS_ACTIVITY_DT_MODE"] != nil {
return XCTestSuite(forTestCaseClass: Self.self)
} else {
return XCTestSuite(name: "Skipping \(className())")
}
}
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
var configFile: URL?
override func setUpWithError() throws {
continueAfterFailure = false
}
override func tearDown() async throws {
if let configFile {
try FileManager.default.removeItem(at: configFile)
}
}
func updateConfig(_ newConfig: String) throws {
if configFile == nil {
let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("ghostty")
configFile = temporaryConfig
}
try newConfig.write(to: configFile!, atomically: true, encoding: .utf8)
}
func ghosttyApplication() throws -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"])
guard let configFile else {
return app
}
app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path
return app
}
}

View File

@@ -0,0 +1,159 @@
//
// GhosttyThemeTests.swift
// Ghostty
//
// Created by luca on 27.10.2025.
//
import AppKit
import XCTest
final class GhosttyThemeTests: GhosttyCustomConfigCase {
let windowTitle = "GhosttyThemeTests"
private func assertTitlebarAppearance(
_ appearance: XCUIDevice.Appearance,
for app: XCUIApplication,
title: String? = nil,
colorLocation: CGPoint? = nil,
file: StaticString = #filePath,
line: UInt = #line
) throws {
for i in 0 ..< app.windows.count {
let titleView = app.windows.element(boundBy: i).staticTexts.element(matching: NSPredicate(format: "value == '\(title ?? windowTitle)'"))
let image = titleView.screenshot().image
guard let imageColor = image.colorAt(x: Int(colorLocation?.x ?? 1), y: Int(colorLocation?.y ?? 1)) else {
throw XCTSkip("failed to get pixel color", file: file, line: line)
}
switch appearance {
case .dark:
XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test", file: file, line: line)
default:
XCTAssertGreaterThanOrEqual(imageColor.luminance, 0.5, "Expected light appearance for this test", file: file, line: line)
}
}
}
/// https://github.com/ghostty-org/ghostty/issues/8282
@MainActor
func testIssue8282() async throws {
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
XCUIDevice.shared.appearance = .dark
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.dark, for: app)
// create a split
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
// reload config
app.typeKey(",", modifierFlags: [.command, .shift])
try await Task.sleep(for: .seconds(0.5))
// create a new window
app.typeKey("n", modifierFlags: [.command])
try assertTitlebarAppearance(.dark, for: app)
}
@MainActor
func testLightTransparentWindowThemeWithDarkTerminal() async throws {
try updateConfig("title=\(windowTitle) \n window-theme=light")
let app = try ghosttyApplication()
app.launch()
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.dark, for: app)
}
@MainActor
func testLightNativeWindowThemeWithDarkTerminal() async throws {
try updateConfig("title=\(windowTitle) \n window-theme = light \n macos-titlebar-style = native")
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.light, for: app)
}
@MainActor
func testReloadingLightTransparentWindowTheme() async throws {
try updateConfig("title=\(windowTitle) \n ")
let app = try ghosttyApplication()
app.launch()
// default dark theme
try assertTitlebarAppearance(.dark, for: app)
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme = light")
// reload config
app.typeKey(",", modifierFlags: [.command, .shift])
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.light, for: app)
}
@MainActor
func testSwitchingSystemTheme() async throws {
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
XCUIDevice.shared.appearance = .dark
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.dark, for: app)
XCUIDevice.shared.appearance = .light
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.light, for: app)
}
@MainActor
func testReloadFromLightWindowThemeToDefaultTheme() async throws {
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
XCUIDevice.shared.appearance = .light
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.light, for: app)
try updateConfig("title=\(windowTitle) \n ")
// reload config
app.typeKey(",", modifierFlags: [.command, .shift])
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.dark, for: app)
}
@MainActor
func testReloadFromDefaultThemeToDarkWindowTheme() async throws {
try updateConfig("title=\(windowTitle) \n ")
XCUIDevice.shared.appearance = .light
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.dark, for: app)
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
// reload config
app.typeKey(",", modifierFlags: [.command, .shift])
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.dark, for: app)
}
@MainActor
func testReloadingFromDarkThemeToSystemLightTheme() async throws {
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
XCUIDevice.shared.appearance = .light
let app = try ghosttyApplication()
app.launch()
try assertTitlebarAppearance(.dark, for: app)
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
// reload config
app.typeKey(",", modifierFlags: [.command, .shift])
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.light, for: app)
}
@MainActor
func testQuickTerminalThemeChange() async throws {
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n confirm-close-surface=false")
XCUIDevice.shared.appearance = .light
let app = try ghosttyApplication()
app.launch()
// close default window
app.typeKey("w", modifierFlags: [.command])
// open quick terminal
app.menuBarItems["View"].firstMatch.click()
app.menuItems["Quick Terminal"].firstMatch.click()
let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development."
try assertTitlebarAppearance(.light, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5)) // to avoid dark edge
XCUIDevice.shared.appearance = .dark
try await Task.sleep(for: .seconds(0.5))
try assertTitlebarAppearance(.dark, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5))
}
}

View File

@@ -0,0 +1,23 @@
//
// GhosttyTitleUITests.swift
// GhosttyUITests
//
// Created by luca on 13.10.2025.
//
import XCTest
final class GhosttyTitleUITests: GhosttyCustomConfigCase {
override func setUp() async throws {
try await super.setUp()
try updateConfig(#"title = "GhosttyUITestsLaunchTests""#)
}
@MainActor
func testTitle() throws {
let app = try ghosttyApplication()
app.launch()
XCTAssertEqual(app.windows.firstMatch.title, "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!")
}
}

View File

@@ -0,0 +1,143 @@
//
// GhosttyTitlebarTabsUITests.swift
// Ghostty
//
// Created by luca on 16.10.2025.
//
import XCTest
final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase {
override func setUp() async throws {
try await super.setUp()
try updateConfig(
"""
macos-titlebar-style = tabs
title = "GhosttyTitlebarTabsUITests"
"""
)
}
@MainActor
func testCustomTitlebar() throws {
let app = try ghosttyApplication()
app.launch()
// create a split
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
app.typeKey("\n", modifierFlags: [.command, .shift])
let resetZoomButton = app.groups.buttons["ResetZoom"]
let windowTitle = app.windows.firstMatch.title
let titleView = app.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'"))
XCTAssertEqual(titleView.frame.midY, resetZoomButton.frame.midY, accuracy: 1, "Window title should be vertically centered with reset zoom button: \(titleView.frame.midY) != \(resetZoomButton.frame.midY)")
}
@MainActor
func testTabsGeometryInNormalWindow() throws {
let app = try ghosttyApplication()
app.launch()
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
checkTabsGeometry(app.windows.firstMatch)
}
@MainActor
func testTabsGeometryInFullscreen() throws {
let app = try ghosttyApplication()
app.launch()
app.typeKey("f", modifierFlags: [.command, .control])
// using app to type +t might not be able to create tabs
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
checkTabsGeometry(app.windows.firstMatch)
}
@MainActor
func testTabsGeometryAfterMovingTabs() throws {
let app = try ghosttyApplication()
app.launch()
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
// create another 2 tabs
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
// move to the left
app.menuItems["_zoomLeft:"].firstMatch.click()
// create another window with 2 tabs
app.windows.firstMatch.groups["Terminal pane"].typeKey("n", modifierFlags: .command)
XCTAssertEqual(app.windows.count, 2, "There should be 2 windows")
// move to the right
app.menuItems["_zoomRight:"].firstMatch.click()
// now second window is the first/main one in the list
app.windows.firstMatch.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
app.windows.element(boundBy: 1).tabs.firstMatch.click() // focus first window
// now the first window is the main one
let firstTabInFirstWindow = app.windows.firstMatch.tabs.firstMatch
let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.firstMatch
// drag a tab from one window to another
firstTabInFirstWindow.press(forDuration: 0.2, thenDragTo: firstTabInSecondWindow)
// check tabs in the first
checkTabsGeometry(app.windows.firstMatch)
// focus another window
app.windows.element(boundBy: 1).tabs.firstMatch.click()
checkTabsGeometry(app.windows.firstMatch)
}
@MainActor
func testTabsGeometryAfterMergingAllWindows() throws {
let app = try ghosttyApplication()
app.launch()
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
// create another 2 windows
app.typeKey("n", modifierFlags: .command)
app.typeKey("n", modifierFlags: .command)
// merge into one window, resulting 3 tabs
app.menuItems["mergeAllWindows:"].firstMatch.click()
XCTAssertTrue(app.wait(for: \.tabs.count, toEqual: 3, timeout: 1), "There should be 3 tabs")
checkTabsGeometry(app.windows.firstMatch)
}
func checkTabsGeometry(_ window: XCUIElement) {
let closeTabButtons = window.buttons.matching(identifier: "_closeButton")
XCTAssertEqual(closeTabButtons.count, window.tabs.count, "Close tab buttons count should match tabs count")
var previousTabHeight: CGFloat?
for idx in 0 ..< window.tabs.count {
let currentTab = window.tabs.element(boundBy: idx)
// focus
currentTab.click()
// switch to the tab
window.typeKey("\(idx + 1)", modifierFlags: .command)
// add a split
window.typeKey("d", modifierFlags: .command)
// zoom this split
// haven't found a way to locate our reset zoom button yet..
window.typeKey("\n", modifierFlags: [.command, .shift])
window.typeKey("\n", modifierFlags: [.command, .shift])
if let previousHeight = previousTabHeight {
XCTAssertEqual(currentTab.frame.height, previousHeight, accuracy: 1, "The tab's height should stay the same")
}
previousTabHeight = currentTab.frame.height
let titleFrame = currentTab.frame
let shortcutLabelFrame = window.staticTexts.element(matching: NSPredicate(format: "value CONTAINS[c] '⌘\(idx + 1)'")).firstMatch.frame
let closeButtonFrame = closeTabButtons.element(boundBy: idx).frame
XCTAssertEqual(titleFrame.midY, shortcutLabelFrame.midY, accuracy: 1, "Tab title should be vertically centered with its shortcut label: \(titleFrame.midY) != \(shortcutLabelFrame.midY)")
XCTAssertEqual(titleFrame.midY, closeButtonFrame.midY, accuracy: 1, "Tab title should be vertically centered with its close button: \(titleFrame.midY) != \(closeButtonFrame.midY)")
}
}
}

View File

@@ -1,8 +1,16 @@
import SwiftUI
import GhosttyKit
@main
struct Ghostty_iOSApp: App {
@StateObject private var ghostty_app = Ghostty.App()
@StateObject private var ghostty_app: Ghostty.App
init() {
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
preconditionFailure("Initialize ghostty backend failed")
}
_ghostty_app = StateObject(wrappedValue: Ghostty.App())
}
var body: some Scene {
WindowGroup {

View File

@@ -0,0 +1,23 @@
import AppKit
// MARK: Ghostty Delegate
/// This implements the Ghostty app delegate protocol which is used by the Ghostty
/// APIs for app-global information.
extension AppDelegate: Ghostty.Delegate {
func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? {
for window in NSApp.windows {
guard let controller = window.windowController as? BaseTerminalController else {
continue
}
for surface in controller.surfaceTree {
if surface.id == id {
return surface
}
}
}
return nil
}
}

View File

@@ -46,6 +46,8 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuFindParent: NSMenuItem?
@IBOutlet private var menuFind: NSMenuItem?
@IBOutlet private var menuSelectionForFind: NSMenuItem?
@IBOutlet private var menuScrollToSelection: NSMenuItem?
@IBOutlet private var menuFindNext: NSMenuItem?
@IBOutlet private var menuFindPrevious: NSMenuItem?
@IBOutlet private var menuHideFindBar: NSMenuItem?
@@ -68,6 +70,8 @@ class AppDelegate: NSObject,
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuChangeTitle: NSMenuItem?
@IBOutlet private var menuChangeTabTitle: NSMenuItem?
@IBOutlet private var menuReadonly: NSMenuItem?
@IBOutlet private var menuQuickTerminal: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
@IBOutlet private var menuCommandPalette: NSMenuItem?
@@ -92,16 +96,40 @@ class AppDelegate: NSObject,
private var derivedConfig: DerivedConfig = DerivedConfig()
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
let ghostty: Ghostty.App
/// The global undo manager for app-level state such as window restoration.
lazy var undoManager = ExpiringUndoManager()
/// The current state of the quick terminal.
private var quickTerminalControllerState: QuickTerminalState = .uninitialized
/// Our quick terminal. This starts out uninitialized and only initializes if used.
private(set) lazy var quickController = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition
)
var quickController: QuickTerminalController {
switch quickTerminalControllerState {
case .initialized(let controller):
return controller
case .pendingRestore(let state):
let controller = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition,
baseConfig: state.baseConfig,
restorationState: state
)
quickTerminalControllerState = .initialized(controller)
return controller
case .uninitialized:
let controller = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition,
restorationState: nil
)
quickTerminalControllerState = .initialized(controller)
return controller
}
}
/// Manages updates
let updateController = UpdateController()
@@ -127,6 +155,11 @@ class AppDelegate: NSObject,
@Published private(set) var appIcon: NSImage? = nil
override init() {
#if DEBUG
ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"])
#else
ghostty = Ghostty.App()
#endif
super.init()
ghostty.delegate = self
@@ -541,8 +574,9 @@ class AppDelegate: NSObject,
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
@@ -588,6 +622,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)
@@ -609,6 +645,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
@@ -657,6 +694,18 @@ class AppDelegate: NSObject,
}
private func localEventKeyDown(_ event: NSEvent) -> NSEvent? {
// If the tab overview is visible and escape is pressed, close it.
// This can't POSSIBLY be right and is probably a FirstResponder problem
// that we should handle elsewhere in our program. But this works and it
// is guarded by the tab overview currently showing.
if event.keyCode == 0x35, // Escape key
let window = NSApp.keyWindow,
let tabGroup = window.tabGroup,
tabGroup.isOverviewVisible {
window.toggleTabOverview(nil)
return nil
}
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event }
@@ -902,33 +951,8 @@ class AppDelegate: NSObject,
var appIconName: String? = config.macosIcon.rawValue
switch (config.macosIcon) {
case .official:
// Discard saved icon name
appIconName = nil
break
case .blueprint:
appIcon = NSImage(named: "BlueprintImage")!
case .chalkboard:
appIcon = NSImage(named: "ChalkboardImage")!
case .glass:
appIcon = NSImage(named: "GlassImage")!
case .holographic:
appIcon = NSImage(named: "HolographicImage")!
case .microchip:
appIcon = NSImage(named: "MicrochipImage")!
case .paper:
appIcon = NSImage(named: "PaperImage")!
case .retro:
appIcon = NSImage(named: "RetroImage")!
case .xray:
appIcon = NSImage(named: "XrayImage")!
case let icon where icon.assetName != nil:
appIcon = NSImage(named: icon.assetName!)!
case .custom:
if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) {
@@ -938,6 +962,7 @@ class AppDelegate: NSObject,
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
@@ -953,10 +978,20 @@ class AppDelegate: NSObject,
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
.joined(separator: "_")
default:
// Discard saved icon name
appIconName = nil
}
// Only change the icon if it has actually changed
// from the current one
guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else {
// 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 {
@@ -973,14 +1008,16 @@ class AppDelegate: NSObject,
let newIcon = appIcon
let appPath = Bundle.main.bundlePath
NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: [])
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
@@ -992,10 +1029,31 @@ class AppDelegate: NSObject,
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
Self.logger.debug("application will save window state")
guard ghostty.config.windowSaveState != "never" else { return }
// Encode our quick terminal state if we have it.
switch quickTerminalControllerState {
case .initialized(let controller) where controller.restorable:
let data = QuickTerminalRestorableState(from: controller)
data.encode(with: coder)
case .pendingRestore(let state):
state.encode(with: coder)
default:
break
}
}
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
Self.logger.debug("application will restore window state")
// Decode our quick terminal state.
if ghostty.config.windowSaveState != "never",
let state = QuickTerminalRestorableState(coder: coder) {
quickTerminalControllerState = .pendingRestore(state)
}
}
//MARK: - UNUserNotificationCenterDelegate
@@ -1269,6 +1327,16 @@ extension AppDelegate: NSMenuItemValidation {
}
}
/// Represents the state of the quick terminal controller.
private enum QuickTerminalState {
/// Controller has not been initialized and has no pending restoration state.
case uninitialized
/// Restoration state is pending; controller will use this when first accessed.
case pendingRestore(QuickTerminalRestorableState)
/// Controller has been initialized.
case initialized(QuickTerminalController)
}
@globalActor
fileprivate actor AppIconActor: GlobalActor {
static let shared = AppIconActor()

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -16,6 +16,7 @@
<connections>
<outlet property="menuAbout" destination="5kV-Vb-QxS" id="Y5y-UO-NK6"/>
<outlet property="menuBringAllToFront" destination="LE2-aR-0XJ" id="AP9-oK-60V"/>
<outlet property="menuChangeTabTitle" destination="iac-lh-Cl7" id="tId-v0-a3E"/>
<outlet property="menuChangeTitle" destination="24I-xg-qIq" id="kg6-kT-jNL"/>
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
<outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
@@ -46,6 +47,7 @@
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
<outlet property="menuReadonly" destination="xpe-ia-Yjw" id="MMT-Sl-AfD"/>
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
@@ -56,6 +58,7 @@
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
@@ -279,6 +282,19 @@
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="2N8-Xz-RVc"/>
<menuItem title="Use Selection for Find" id="TDN-42-Bu7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" id="1rN-4k-Dz3">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="scrollToSelection:" target="-1" id="5gS-8h-Xm2"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
@@ -315,12 +331,24 @@
<action selector="toggleCommandPalette:" target="-1" id="FcT-XD-gM1"/>
</connections>
</menuItem>
<menuItem title="Change Title..." id="24I-xg-qIq">
<menuItem title="Change Tab Title..." id="iac-lh-Cl7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="changeTabTitle:" target="-1" id="Jhl-9P-bMj"/>
</connections>
</menuItem>
<menuItem title="Change Terminal Title..." id="24I-xg-qIq">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="changeTitle:" target="-1" id="XuL-QB-Q9l"/>
</connections>
</menuItem>
<menuItem title="Terminal Read-only" id="xpe-ia-Yjw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleReadonly:" target="-1" id="Gqx-wT-K9v"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Vkj-tP-dMZ"/>
<menuItem title="Quick Terminal" id="1pv-LF-NBJ">
<modifierMask key="keyEquivalentModifierMask"/>

View File

@@ -44,10 +44,7 @@ struct AboutView: View {
var body: some View {
VStack(alignment: .center) {
ghosttyIconImage()
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 128)
CyclingIconView()
VStack(alignment: .center, spacing: 32) {
VStack(alignment: .center, spacing: 8) {

View File

@@ -0,0 +1,62 @@
import SwiftUI
import GhosttyKit
/// 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)
var body: some View {
ZStack {
iconView(for: currentIcon)
.id(currentIcon)
}
.animation(.easeInOut(duration: 0.5), value: currentIcon)
.frame(height: 128)
.onReceive(timerPublisher.autoconnect()) { _ in
if !isHovering {
advanceToNextIcon()
}
}
.onHover { hovering in
isHovering = hovering
}
.onTapGesture {
advanceToNextIcon()
}
.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 {
case let assetName?: Image(assetName)
case nil: ghosttyIconImage()
}
iconImage
.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,4 +1,5 @@
import AppIntents
import Cocoa
// MARK: AppEntity
@@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery {
@MainActor
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
let commands = appDelegate.ghostty.config.commandPaletteEntries
// Extract unique terminal IDs to avoid fetching duplicates
let terminalIds = Set(identifiers.map(\.terminalId))
let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
// Build a cache of terminals and their available commands
// This avoids repeated command fetching for the same terminal
typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
let commandMap: [TerminalEntity.ID: Tuple] =
// Build a lookup from terminal ID to terminal entity
let terminalMap: [TerminalEntity.ID: TerminalEntity] =
terminals.reduce(into: [:]) { result, terminal in
guard let commands = try? terminal.surfaceModel?.commands() else { return }
result[terminal.id] = (terminal: terminal, commands: commands)
result[terminal.id] = terminal
}
// Map each identifier to its corresponding CommandEntity. If a command doesn't
// exist it maps to nil and is removed via compactMap.
return identifiers.compactMap { id in
guard let (terminal, commands) = commandMap[id.terminalId],
guard let terminal = terminalMap[id.terminalId],
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
return nil
}
@@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery {
@MainActor
func suggestedEntities() async throws -> [CommandEntity] {
guard let terminal = commandPaletteIntent?.terminal,
let surface = terminal.surfaceModel else { return [] }
return try surface.commands().map { CommandEntity($0, for: terminal) }
guard let appDelegate = NSApp.delegate as? AppDelegate,
let terminal = commandPaletteIntent?.terminal else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) }
}
}

View File

@@ -1,30 +1,50 @@
import SwiftUI
struct CommandOption: Identifiable, Hashable {
/// Unique identifier for this option.
let id = UUID()
/// The primary text displayed for this command.
let title: String
/// Secondary text displayed below the title.
let subtitle: String?
/// Tooltip text shown on hover.
let description: String?
/// Keyboard shortcut symbols to display.
let symbols: [String]?
/// SF Symbol name for the leading icon.
let leadingIcon: String?
/// Color for the leading indicator circle.
let leadingColor: Color?
/// Badge text displayed as a pill.
let badge: String?
/// Whether to visually emphasize this option.
let emphasis: Bool
/// Sort key for stable ordering when titles are equal.
let sortKey: AnySortKey?
/// The action to perform when this option is selected.
let action: () -> Void
init(
title: String,
subtitle: String? = nil,
description: String? = nil,
symbols: [String]? = nil,
leadingIcon: String? = nil,
leadingColor: Color? = nil,
badge: String? = nil,
emphasis: Bool = false,
sortKey: AnySortKey? = nil,
action: @escaping () -> Void
) {
self.title = title
self.subtitle = subtitle
self.description = description
self.symbols = symbols
self.leadingIcon = leadingIcon
self.leadingColor = leadingColor
self.badge = badge
self.emphasis = emphasis
self.sortKey = sortKey
self.action = action
}
@@ -47,12 +67,24 @@ struct CommandPaletteView: View {
@FocusState private var isTextFieldFocused: Bool
// The options that we should show, taking into account any filtering from
// the query.
// the query. Options with matching leadingColor are ranked higher.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
// Sort by color match score (higher scores first), then maintain original order
return filtered.sorted { a, b in
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
return scoreA > scoreB
}
}
}
@@ -168,6 +200,32 @@ struct CommandPaletteView: View {
isTextFieldFocused = isPresented
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
private func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}
/// The text field for building the query for the command palette.
@@ -283,14 +341,28 @@ fileprivate struct CommandRow: View {
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if let color = option.leadingColor {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.font(.system(size: 14, weight: .medium))
}
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
if let subtitle = option.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()

View File

@@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View {
/// The callback when an action is submitted.
var onAction: ((String) -> Void)
// The commands available to the command palette.
private var commandOptions: [CommandOption] {
var options: [CommandOption] = []
// Add update command if an update is installable. This must always be the first so
// it is at the top.
if let updateViewModel, updateViewModel.state.isInstallable {
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
}
// Add cancel/skip update command if the update is installable
if let updateViewModel, updateViewModel.state.isInstallable {
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
}
// Add terminal commands
guard let surface = surfaceView.surfaceModel else { return options }
do {
let terminalCommands = try surface.commands().map { c in
return CommandOption(
title: c.title,
description: c.description,
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
) {
onAction(c.action)
}
}
options.append(contentsOf: terminalCommands)
} catch {
return options
}
return options
}
var body: some View {
ZStack {
if isPresented {
@@ -96,13 +39,8 @@ struct TerminalCommandPaletteView: View {
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.transition(
.move(edge: .top)
.combined(with: .opacity)
)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented)
.onChange(of: isPresented) { newValue in
// When the command palette disappears we need to send focus back to the
// surface view we were overlaid on top of. There's probably a better way
@@ -116,6 +54,116 @@ struct TerminalCommandPaletteView: View {
}
}
}
/// All commands available in the command palette, combining update and terminal options.
private var commandOptions: [CommandOption] {
var options: [CommandOption] = []
// Updates always appear first
options.append(contentsOf: updateOptions)
// Sort the rest. We replace ":" with a character that sorts before space
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
// for stable ordering when titles are equal.
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized)
if comparison != .orderedSame {
return comparison == .orderedAscending
}
// Tie-breaker: use sortKey if both have one
if let aSortKey = a.sortKey, let bSortKey = b.sortKey {
return aSortKey < bSortKey
}
return false
})
return options
}
/// Commands for installing or canceling available updates.
private var updateOptions: [CommandOption] {
var options: [CommandOption] = []
guard let updateViewModel, updateViewModel.state.isInstallable else {
return options
}
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
return options
}
/// Custom commands from the command-palette-entry configuration.
private var terminalOptions: [CommandOption] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
CommandOption(
title: c.title,
description: c.description
) {
onAction(c.action)
}
}
}
/// Commands for jumping to other terminal surfaces.
private var jumpOptions: [CommandOption] {
TerminalController.all.flatMap { controller -> [CommandOption] in
guard let window = controller.window else { return [] }
let color = (window as? TerminalWindow)?.tabColor
let displayColor = color != TerminalTabColor.none ? color : nil
return controller.surfaceTree.map { surface in
let title = surface.title.isEmpty ? window.title : surface.title
let displayTitle = title.isEmpty ? "Untitled" : title
let pwd = surface.pwd?.abbreviatedPath
let subtitle: String? = if let pwd, !displayTitle.contains(pwd) {
pwd
} else {
nil
}
return CommandOption(
title: "Focus: \(displayTitle)",
subtitle: subtitle,
leadingIcon: "rectangle.on.rectangle",
leadingColor: displayColor?.displayColor.map { Color($0) },
sortKey: AnySortKey(ObjectIdentifier(surface))
) {
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyPresentTerminal,
object: surface
)
}
}
}
}
}
/// This is done to ensure that the given view is in the responder chain.

View File

@@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController {
private var previousActiveSpace: CGSSpace? = nil
/// Cache for per-screen window state.
private let screenStateCache = QuickTerminalScreenStateCache()
let screenStateCache: QuickTerminalScreenStateCache
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController {
/// Tracks if we're currently handling a manual resize to prevent recursion
private var isHandlingResize: Bool = false
/// This is set to false by init if the window managed by this controller should not be restorable.
/// For example, terminals executing custom scripts are not restorable.
let restorable: Bool
private var restorationState: QuickTerminalRestorableState?
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
restorationState: QuickTerminalRestorableState? = nil,
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
// time of writing this: it'd just restore to a shell in the same directory
// as the script. We may want to revisit this behavior when we have scrollback
// restoration.
restorable = (base?.command ?? "") == ""
self.restorationState = restorationState
self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:])
// Important detail here: we initialize with an empty surface tree so
// that we don't start a terminal process. This gets started when the
// first terminal is shown in `animateIn`.
@@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController {
// window close so we can animate out.
window.delegate = self
// The quick window is not restorable (yet!). "Yet" because in theory we can
// make this restorable, but it isn't currently implemented.
// The quick window is restored by `screenStateCache`.
// We disable this for better control
window.isRestorable = false
// Setup our configured appearance that we support.
@@ -125,11 +137,11 @@ class QuickTerminalController: BaseTerminalController {
}
// Setup our content
window.contentView = NSHostingView(rootView: TerminalView(
window.contentView = TerminalViewContainer(
ghostty: self.ghostty,
viewModel: self,
delegate: self
))
)
// Clear out our frame at this point, the fixup from above is complete.
if let qtWindow = window as? QuickTerminalWindow {
@@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController {
// animate out.
if surfaceTree.isEmpty,
let ghostty_app = ghostty.app {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
surfaceTree = SplitTree(view: view)
focusedSurface = view
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
surfaceTree = tree
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
focusedSurface = view
// Add a short delay to check if the correct surface is focused.
// Each SurfaceWrapper defaults its FocusedValue to itself; without this delay,
// the tree often focuses the first surface instead of the intended one.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if !view.focused {
self.focusedSurface = view
self.makeWindowKey(window)
}
}
} else {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
surfaceTree = SplitTree(view: view)
focusedSurface = view
}
}
// Animate the window in
animateWindowIn(window: window, from: position)
// Clear the restoration state after first use
restorationState = nil
}
func animateOut() {
@@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController {
animateWindowOut(window: window, to: position)
}
func saveScreenState(exitFullscreen: Bool) {
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
// we have th correct window.frame for the save state below.
if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
}
guard let window else { return }
// Save the current window frame before animating out. This preserves
// the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
screenStateCache.save(frame: window.frame, for: screen)
}
}
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
@@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
// we have th correct window.frame for the save state below.
if let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
}
// Save the current window frame before animating out. This preserves
// the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
screenStateCache.save(frame: window.frame, for: screen)
}
saveScreenState(exitFullscreen: true)
// If we hid the dock then we unhide it.
hiddenDock = nil
@@ -563,9 +596,10 @@ class QuickTerminalController: BaseTerminalController {
})
}
private func syncAppearance() {
override func syncAppearance() {
guard let window else { return }
defer { updateColorSchemeForSurfaceTree() }
// Change the collection behavior of the window depending on the configuration.
window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior
@@ -574,7 +608,8 @@ class QuickTerminalController: BaseTerminalController {
guard window.isVisible else { return }
// If we have window transparency then set it transparent. Otherwise set it opaque.
if (self.derivedConfig.backgroundOpacity < 1) {
// Also check if the user has overridden transparency to be fully opaque.
if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
@@ -582,7 +617,9 @@ class QuickTerminalController: BaseTerminalController {
// Terminal.app more easily.
window.backgroundColor = .white.withAlphaComponent(0.001)
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
if !derivedConfig.backgroundBlur.isGlassStyle {
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
}
} else {
window.isOpaque = true
window.backgroundColor = .windowBackgroundColor
@@ -687,6 +724,7 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let quickTerminalSize: QuickTerminalSize
let backgroundOpacity: Double
let backgroundBlur: Ghostty.Config.BackgroundBlur
init() {
self.quickTerminalScreen = .main
@@ -695,6 +733,7 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalSpaceBehavior = .move
self.quickTerminalSize = QuickTerminalSize()
self.backgroundOpacity = 1.0
self.backgroundBlur = .disabled
}
init(_ config: Ghostty.Config) {
@@ -704,6 +743,7 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.quickTerminalSize = config.quickTerminalSize
self.backgroundOpacity = config.backgroundOpacity
self.backgroundBlur = config.backgroundBlur
}
}

View File

@@ -0,0 +1,26 @@
import Cocoa
struct QuickTerminalRestorableState: TerminalRestorable {
static var version: Int { 1 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let screenStateEntries: QuickTerminalScreenStateCache.Entries
init(from controller: QuickTerminalController) {
controller.saveScreenState(exitFullscreen: true)
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.screenStateEntries = controller.screenStateCache.stateByDisplay
}
init(copy other: QuickTerminalRestorableState) {
self = other
}
var baseConfig: Ghostty.SurfaceConfiguration? {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
return config
}
}

View File

@@ -7,6 +7,8 @@ import Cocoa
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
/// to survive NSScreen garbage collection and automatically prunes stale entries.
class QuickTerminalScreenStateCache {
typealias Entries = [UUID: DisplayEntry]
/// The maximum number of saved screen states we retain. This is to avoid some kind of
/// pathological memory growth in case we get our screen state serializing wrong. I don't
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
@@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache {
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
/// Keyed by display UUID to survive NSScreen garbage collection.
private var stateByDisplay: [UUID: DisplayEntry] = [:]
init() {
private(set) var stateByDisplay: Entries = [:]
init(stateByDisplay: Entries = [:]) {
self.stateByDisplay = stateByDisplay
NotificationCenter.default.addObserver(
self,
selector: #selector(onScreensChanged(_:)),
@@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
}
}
private struct DisplayEntry {
struct DisplayEntry: Codable {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat

View File

@@ -121,10 +121,10 @@ extension SplitTree {
/// Insert a new view at the given view point by creating a split in the given direction.
/// This will always reset the zoomed state of the tree.
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
return .init(
root: try root.insert(view: view, at: at, direction: direction),
root: try root.inserting(view: view, at: at, direction: direction),
zoomed: nil)
}
/// Find a node containing a view with the specified ID.
@@ -137,7 +137,7 @@ extension SplitTree {
/// Remove a node from the tree. If the node being removed is part of a split,
/// the sibling node takes the place of the parent split.
func remove(_ target: Node) -> Self {
func removing(_ target: Node) -> Self {
guard let root else { return self }
// If we're removing the root itself, return an empty tree
@@ -155,7 +155,7 @@ extension SplitTree {
}
/// Replace a node in the tree with a new node.
func replace(node: Node, with newNode: Node) throws -> Self {
func replacing(node: Node, with newNode: Node) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
// Get the path to the node we want to replace
@@ -164,7 +164,7 @@ extension SplitTree {
}
// Replace the node
let newRoot = try root.replaceNode(at: path, with: newNode)
let newRoot = try root.replacingNode(at: path, with: newNode)
// Update zoomed if it was the replaced node
let newZoomed = (zoomed == node) ? newNode : zoomed
@@ -232,7 +232,7 @@ extension SplitTree {
/// Equalize all splits in the tree so that each split's ratio is based on the
/// relative weight (number of leaves) of its children.
func equalize() -> Self {
func equalized() -> Self {
guard let root else { return self }
let newRoot = root.equalize()
return .init(root: newRoot, zoomed: zoomed)
@@ -255,7 +255,7 @@ extension SplitTree {
/// - bounds: The bounds used to construct the spatial tree representation
/// - Returns: A new SplitTree with the adjusted split ratios
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
func resizing(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
guard let root else { throw SplitError.viewNotFound }
// Find the path to the target node
@@ -327,7 +327,7 @@ extension SplitTree {
)
// Replace the split node with the new one
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit))
return .init(root: newRoot, zoomed: nil)
}
@@ -508,7 +508,7 @@ extension SplitTree.Node {
///
/// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
/// maybe throw instead but at the moment we just do nothing.
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
// Get the path to our insertion point. If it doesn't exist we do
// nothing.
guard let path = path(to: .leaf(view: at)) else {
@@ -544,11 +544,11 @@ extension SplitTree.Node {
))
// Replace the node at the path with the new split
return try replaceNode(at: path, with: newSplit)
return try replacingNode(at: path, with: newSplit)
}
/// Helper function to replace a node at the given path from the root
func replaceNode(at path: Path, with newNode: Self) throws -> Self {
func replacingNode(at path: Path, with newNode: Self) throws -> Self {
// If path is empty, replace the root
if path.isEmpty {
return newNode
@@ -635,7 +635,7 @@ extension SplitTree.Node {
/// Resize a split node to the specified ratio.
/// For leaf nodes, this returns the node unchanged.
/// For split nodes, this creates a new split with the updated ratio.
func resize(to ratio: Double) -> Self {
func resizing(to ratio: Double) -> Self {
switch self {
case .leaf:
// Leaf nodes don't have a ratio to resize

View File

@@ -1,15 +1,40 @@
import SwiftUI
/// A single operation within the split tree.
///
/// Rather than binding the split tree (which is immutable), any mutable operations are
/// exposed via this enum to the embedder to handle.
enum TerminalSplitOperation {
case resize(Resize)
case drop(Drop)
struct Resize {
let node: SplitTree<Ghostty.SurfaceView>.Node
let ratio: Double
}
struct Drop {
/// The surface being dragged.
let payload: Ghostty.SurfaceView
/// The surface it was dragged onto
let destination: Ghostty.SurfaceView
/// The zone it was dropped to determine how to split the destination.
let zone: TerminalSplitDropZone
}
}
struct TerminalSplitTreeView: View {
let tree: SplitTree<Ghostty.SurfaceView>
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
let action: (TerminalSplitOperation) -> Void
var body: some View {
if let node = tree.zoomed ?? tree.root {
TerminalSplitSubtreeView(
node: node,
isRoot: node == tree.root,
onResize: onResize)
action: action)
// This is necessary because we can't rely on SwiftUI's implicit
// structural identity to detect changes to this view. Due to
// the tree structure of splits it could result in bad behaviors.
@@ -19,21 +44,17 @@ struct TerminalSplitTreeView: View {
}
}
struct TerminalSplitSubtreeView: View {
fileprivate struct TerminalSplitSubtreeView: View {
@EnvironmentObject var ghostty: Ghostty.App
let node: SplitTree<Ghostty.SurfaceView>.Node
var isRoot: Bool = false
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
let action: (TerminalSplitOperation) -> Void
var body: some View {
switch (node) {
case .leaf(let leafView):
Ghostty.InspectableSurface(
surfaceView: leafView,
isSplit: !isRoot)
.accessibilityElement(children: .contain)
.accessibilityLabel("Terminal pane")
TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
case .split(let split):
let splitViewDirection: SplitViewDirection = switch (split.direction) {
@@ -46,15 +67,15 @@ struct TerminalSplitSubtreeView: View {
.init(get: {
CGFloat(split.ratio)
}, set: {
onResize(node, $0)
action(.resize(.init(node: node, ratio: $0)))
}),
dividerColor: ghostty.config.splitDividerColor,
resizeIncrements: .init(width: 1, height: 1),
left: {
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
TerminalSplitSubtreeView(node: split.left, action: action)
},
right: {
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
TerminalSplitSubtreeView(node: split.right, action: action)
},
onEqualize: {
guard let surface = node.leftmostLeaf().surface else { return }
@@ -64,3 +85,173 @@ struct TerminalSplitSubtreeView: View {
}
}
}
fileprivate struct TerminalSplitLeaf: View {
let surfaceView: Ghostty.SurfaceView
let isSplit: Bool
let action: (TerminalSplitOperation) -> Void
@State private var dropState: DropState = .idle
@State private var isSelfDragging: Bool = false
var body: some View {
GeometryReader { geometry in
Ghostty.InspectableSurface(
surfaceView: surfaceView,
isSplit: isSplit)
.background {
// If we're dragging ourself, we hide the entire drop zone. This makes
// it so that a released drop animates back to its source properly
// so it is a proper invalid drop zone.
if !isSelfDragging {
Color.clear
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
dropState: $dropState,
viewSize: geometry.size,
destinationSurface: surfaceView,
action: action
))
}
}
.overlay {
if !isSelfDragging, case .dropping(let zone) = dropState {
zone.overlay(in: geometry)
.allowsHitTesting(false)
}
}
.onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in
isSelfDragging = value == surfaceView.id
if isSelfDragging {
dropState = .idle
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Terminal pane")
}
}
private enum DropState: Equatable {
case idle
case dropping(TerminalSplitDropZone)
}
private struct SplitDropDelegate: DropDelegate {
@Binding var dropState: DropState
let viewSize: CGSize
let destinationSurface: Ghostty.SurfaceView
let action: (TerminalSplitOperation) -> Void
func validateDrop(info: DropInfo) -> Bool {
info.hasItemsConforming(to: [.ghosttySurfaceId])
}
func dropEntered(info: DropInfo) {
dropState = .dropping(.calculate(at: info.location, in: viewSize))
}
func dropUpdated(info: DropInfo) -> DropProposal? {
// For some reason dropUpdated is sent after performDrop is called
// and we don't want to reset our drop zone to show it so we have
// to guard on the state here.
guard case .dropping = dropState else { return DropProposal(operation: .forbidden) }
dropState = .dropping(.calculate(at: info.location, in: viewSize))
return DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
dropState = .idle
}
func performDrop(info: DropInfo) -> Bool {
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
dropState = .idle
// Load the dropped surface asynchronously using Transferable
let providers = info.itemProviders(for: [.ghosttySurfaceId])
guard let provider = providers.first else { return false }
// Capture action before the async closure
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
switch result {
case .success(let sourceSurface):
DispatchQueue.main.async {
// Don't allow dropping on self
guard let destinationSurface else { return }
guard sourceSurface !== destinationSurface else { return }
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
}
case .failure:
break
}
}
return true
}
}
}
enum TerminalSplitDropZone: String, Equatable {
case top
case bottom
case left
case right
/// Determines which drop zone the cursor is in based on proximity to edges.
///
/// Divides the view into four triangular regions by drawing diagonals from
/// corner to corner. The drop zone is determined by which edge the cursor
/// is closest to, creating natural triangular hit regions for each side.
static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone {
let relX = point.x / size.width
let relY = point.y / size.height
let distToLeft = relX
let distToRight = 1 - relX
let distToTop = relY
let distToBottom = 1 - relY
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
if minDist == distToLeft { return .left }
if minDist == distToRight { return .right }
if minDist == distToTop { return .top }
return .bottom
}
@ViewBuilder
func overlay(in geometry: GeometryProxy) -> some View {
let overlayColor = Color.accentColor.opacity(0.3)
switch self {
case .top:
VStack(spacing: 0) {
Rectangle()
.fill(overlayColor)
.frame(height: geometry.size.height / 2)
Spacer()
}
case .bottom:
VStack(spacing: 0) {
Spacer()
Rectangle()
.fill(overlayColor)
.frame(height: geometry.size.height / 2)
}
case .left:
HStack(spacing: 0) {
Rectangle()
.fill(overlayColor)
.frame(width: geometry.size.width / 2)
Spacer()
}
case .right:
HStack(spacing: 0) {
Spacer()
Rectangle()
.fill(overlayColor)
.frame(width: geometry.size.width / 2)
}
}
}
}

View File

@@ -72,12 +72,27 @@ class BaseTerminalController: NSWindowController,
/// The previous frame information from the window
private var savedFrame: SavedFrame? = nil
/// Cache previously applied appearance to avoid unnecessary updates
private var appliedColorScheme: ghostty_color_scheme_e?
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// Track whether background is forced opaque (true) or using config transparency (false)
var isBackgroundOpaque: Bool = false
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
/// An override title for the tab/window set by the user via prompt_tab_title.
/// When set, this takes precedence over the computed title from the terminal.
var titleOverride: String? = nil {
didSet { applyTitleToWindow() }
}
/// The last computed title from the focused surface (without the override).
private var lastComputedTitle: String = "👻"
/// The time that undo/redo operations that contain running ptys are valid for.
var undoExpiration: Duration {
ghostty.config.undoTimeout
@@ -180,6 +195,16 @@ class BaseTerminalController: NSWindowController,
selector: #selector(ghosttyDidResizeSplit(_:)),
name: Ghostty.Notification.didResizeSplit,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyDidPresentTerminal(_:)),
name: Ghostty.Notification.ghosttyPresentTerminal,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttySurfaceDragEndedNoTarget(_:)),
name: .ghosttySurfaceDragEndedNoTarget,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
@@ -215,7 +240,7 @@ class BaseTerminalController: NSWindowController,
// Do the split
let newTree: SplitTree<Ghostty.SurfaceView>
do {
newTree = try surfaceTree.insert(
newTree = try surfaceTree.inserting(
view: newView,
at: oldView,
direction: direction)
@@ -322,6 +347,37 @@ class BaseTerminalController: NSWindowController,
self.alert = alert
}
/// Prompt the user to change the tab/window title.
func promptTabTitle() {
guard let window else { return }
let alert = NSAlert()
alert.messageText = "Change Tab Title"
alert.informativeText = "Leave blank to restore the default."
alert.alertStyle = .informational
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24))
textField.stringValue = titleOverride ?? window.title
alert.accessoryView = textField
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = textField
alert.beginSheetModal(for: window) { [weak self] response in
guard let self else { return }
guard response == .alertFirstButtonReturn else { return }
let newTitle = textField.stringValue
if newTitle.isEmpty {
self.titleOverride = nil
} else {
self.titleOverride = newTitle
}
}
}
/// Close a surface from a view.
func closeSurface(
_ view: Ghostty.SurfaceView,
@@ -394,14 +450,14 @@ class BaseTerminalController: NSWindowController,
}
replaceSurfaceTree(
surfaceTree.remove(node),
surfaceTree.removing(node),
moveFocusTo: nextFocus,
moveFocusFrom: focusedSurface,
undoAction: "Close Terminal"
)
}
private func replaceSurfaceTree(
func replaceSurfaceTree(
_ newTree: SplitTree<Ghostty.SurfaceView>,
moveFocusTo newView: Ghostty.SurfaceView? = nil,
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
@@ -415,33 +471,33 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: newView, from: oldView)
}
}
// Setup our undo
if let undoManager {
if let undoAction {
undoManager.setActionName(undoAction)
guard let undoManager else { return }
if let undoAction {
undoManager.setActionName(undoAction)
}
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
target.surfaceTree = oldTree
if let oldView {
DispatchQueue.main.async {
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
}
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.surfaceTree = oldTree
if let oldView {
DispatchQueue.main.async {
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: target.focusedSurface,
undoAction: undoAction)
}
target.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: target.focusedSurface,
undoAction: undoAction)
}
}
}
@@ -558,7 +614,7 @@ class BaseTerminalController: NSWindowController,
guard surfaceTree.contains(target) else { return }
// Equalize the splits
surfaceTree = surfaceTree.equalize()
surfaceTree = surfaceTree.equalized()
}
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
@@ -578,9 +634,14 @@ class BaseTerminalController: NSWindowController,
return
}
// Remove the zoomed state for this surface tree.
if surfaceTree.zoomed != nil {
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
if derivedConfig.splitPreserveZoom.contains(.navigation) {
surfaceTree = SplitTree(
root: surfaceTree.root,
zoomed: surfaceTree.root?.node(view: nextSurface))
} else {
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
}
}
// Move focus to the next surface
@@ -643,12 +704,64 @@ class BaseTerminalController: NSWindowController,
// Perform the resize using the new SplitTree resize method
do {
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
} catch {
Ghostty.logger.warning("failed to resize split: \(error)")
}
}
@objc private func ghosttyDidPresentTerminal(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
// Bring the window to front and focus the surface.
window?.makeKeyAndOrderFront(nil)
// We use a small delay to ensure this runs after any UI cleanup
// (e.g., command palette restoring focus to its original surface).
Ghostty.moveFocus(to: target)
Ghostty.moveFocus(to: target, delay: 0.1)
// Show a brief highlight to help the user locate the presented terminal.
target.highlight()
}
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// If our tree isn't split, then we never create a new window, because
// it is already a single split.
guard surfaceTree.isSplit else { return }
// If we are removing our focused surface then we move it. We need to
// keep track of our old one so undo sends focus back to the right place.
let oldFocusedSurface = focusedSurface
if focusedSurface == target {
focusedSurface = findNextFocusTargetAfterClosing(node: targetNode)
}
// Remove the surface from our tree
let removedTree = surfaceTree.removing(targetNode)
// Create a new tree with the dragged surface and open a new window
let newTree = SplitTree<Ghostty.SurfaceView>(view: target)
// Treat our undo below as a full group.
undoManager?.beginUndoGrouping()
undoManager?.setActionName("Move Split")
defer {
undoManager?.endUndoGrouping()
}
replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface)
_ = TerminalController.newWindow(
ghostty,
tree: newTree,
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
confirmUndo: false)
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
@@ -715,10 +828,21 @@ class BaseTerminalController: NSWindowController,
}
private func titleDidChange(to: String) {
lastComputedTitle = to
applyTitleToWindow()
}
private func applyTitleToWindow() {
guard let window else { return }
// Set the main window title
window.title = to
if let titleOverride {
window.title = computeTitle(
title: titleOverride,
bell: focusedSurface?.bell ?? false)
return
}
window.title = lastComputedTitle
}
func pwdDidChange(to: URL?) {
@@ -742,14 +866,101 @@ class BaseTerminalController: NSWindowController,
self.window?.contentResizeIncrements = to
}
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
let resizedNode = node.resize(to: newRatio)
func performSplitAction(_ action: TerminalSplitOperation) {
switch action {
case .resize(let resize):
splitDidResize(node: resize.node, to: resize.ratio)
case .drop(let drop):
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
}
}
private func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
let resizedNode = node.resizing(to: newRatio)
do {
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
} catch {
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
}
}
private func splitDidDrop(
source: Ghostty.SurfaceView,
destination: Ghostty.SurfaceView,
zone: TerminalSplitDropZone
) {
// Map drop zone to split direction
let direction: SplitTree<Ghostty.SurfaceView>.NewDirection = switch zone {
case .top: .up
case .bottom: .down
case .left: .left
case .right: .right
}
// Check if source is in our tree
if let sourceNode = surfaceTree.root?.node(view: source) {
// Source is in our tree - same window move
let treeWithoutSource = surfaceTree.removing(sourceNode)
let newTree: SplitTree<Ghostty.SurfaceView>
do {
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
} catch {
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
return
}
replaceSurfaceTree(
newTree,
moveFocusTo: source,
moveFocusFrom: focusedSurface,
undoAction: "Move Split")
return
}
// Source is not in our tree - search other windows
var sourceController: BaseTerminalController?
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
for window in NSApp.windows {
guard let controller = window.windowController as? BaseTerminalController else { continue }
guard controller !== self else { continue }
if let node = controller.surfaceTree.root?.node(view: source) {
sourceController = controller
sourceNode = node
break
}
}
guard let sourceController, let sourceNode else {
Ghostty.logger.warning("source surface not found in any window during drop")
return
}
// Remove from source controller's tree and add it to our tree.
// We do this first because if there is an error then we can
// abort.
let newTree: SplitTree<Ghostty.SurfaceView>
do {
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
} catch {
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
return
}
// Treat our undo below as a full group.
undoManager?.beginUndoGrouping()
undoManager?.setActionName("Move Split")
defer {
undoManager?.endUndoGrouping()
}
// Remove the node from the source.
sourceController.removeSurfaceNode(sourceNode)
// Add in the surface to our tree
replaceSurfaceTree(
newTree,
moveFocusTo: source,
moveFocusFrom: focusedSurface)
}
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
@@ -761,6 +972,35 @@ class BaseTerminalController: NSWindowController,
}
}
// MARK: Appearance
/// Toggle the background opacity between transparent and opaque states.
/// Do nothing if the configured background-opacity is >= 1 (already opaque).
/// Subclasses should override this to add platform-specific checks and sync appearance.
func toggleBackgroundOpacity() {
// Do nothing if config is already fully opaque
guard ghostty.config.backgroundOpacity < 1 else { return }
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
guard let window, !window.styleMask.contains(.fullScreen) else { return }
// Toggle between transparent and opaque
isBackgroundOpaque.toggle()
// Update our appearance
syncAppearance()
}
/// Override this to resync any appearance related properties. This will be called automatically
/// when certain window properties change that affect appearance. The list below should be updated
/// as we add new things:
///
/// - ``toggleBackgroundOpacity``
func syncAppearance() {
// Purposely a no-op. This lets subclasses override this and we can call
// it virtually from here.
}
// MARK: Fullscreen
/// Toggle fullscreen for the given mode.
@@ -821,6 +1061,9 @@ class BaseTerminalController: NSWindowController,
} else {
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
// Always resync our appearance
syncAppearance()
}
// MARK: Clipboard Confirmation
@@ -969,6 +1212,15 @@ class BaseTerminalController: NSWindowController,
}
func windowDidBecomeKey(_ notification: Notification) {
// If when we become key our first responder is the window itself, then we
// want to move focus to our focused terminal surface. This works around
// various weirdness with moving surfaces around.
if let window, window.firstResponder == window, let focusedSurface {
DispatchQueue.main.async {
Ghostty.moveFocus(to: focusedSurface)
}
}
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
@@ -1014,6 +1266,10 @@ class BaseTerminalController: NSWindowController,
window.performClose(sender)
}
@IBAction func changeTabTitle(_ sender: Any) {
promptTabTitle()
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT)
@@ -1116,7 +1372,15 @@ class BaseTerminalController: NSWindowController,
@IBAction func find(_ sender: Any) {
focusedSurface?.find(sender)
}
@IBAction func selectionForFind(_ sender: Any) {
focusedSurface?.selectionForFind(sender)
}
@IBAction func scrollToSelection(_ sender: Any) {
focusedSurface?.scrollToSelection(sender)
}
@IBAction func findNext(_ sender: Any) {
focusedSurface?.findNext(sender)
}
@@ -1138,17 +1402,20 @@ class BaseTerminalController: NSWindowController,
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool
let focusFollowsMouse: Bool
let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom
init() {
self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false
self.focusFollowsMouse = false
self.splitPreserveZoom = .init()
}
init(_ config: Ghostty.Config) {
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize
self.focusFollowsMouse = config.focusFollowsMouse
self.splitPreserveZoom = config.splitPreserveZoom
}
}
}
@@ -1163,4 +1430,35 @@ extension BaseTerminalController: NSMenuItemValidation {
return true
}
}
// MARK: - Surface Color Scheme
/// Update the surface tree's color scheme only when it actually changes.
///
/// Calling ``ghostty_surface_set_color_scheme`` triggers
/// ``syncAppearance(_:)`` via notification,
/// so we avoid redundant calls.
func updateColorSchemeForSurfaceTree() {
/// Derive the target scheme from `window-theme` or system appearance.
/// We set the scheme on surfaces so they pick the correct theme
/// and let ``syncAppearance(_:)`` update the window accordingly.
///
/// Using App's effectiveAppearance here to prevent incorrect updates.
let themeAppearance = NSApplication.shared.effectiveAppearance
let scheme: ghostty_color_scheme_e
if themeAppearance.isDark {
scheme = GHOSTTY_COLOR_SCHEME_DARK
} else {
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
}
guard scheme != appliedColorScheme else {
return
}
for surfaceView in surfaceTree {
if let surface = surfaceView.surface {
ghostty_surface_set_color_scheme(surface, scheme)
}
}
appliedColorScheme = scheme
}
}

View File

@@ -8,16 +8,16 @@ import GhosttyKit
class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
override var windowNibName: NSNib.Name? {
let defaultValue = "Terminal"
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
let config = appDelegate.ghostty.config
// If we have no window decorations, there's no reason to do anything but
// the default titlebar (because there will be no titlebar).
if !config.windowDecorations {
return defaultValue
}
let nib = switch config.macosTitlebarStyle {
case "native": "Terminal"
case "hidden": "TerminalHiddenTitlebar"
@@ -34,32 +34,33 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
#endif
default: defaultValue
}
return nib
}
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
/// early if we don't care.
private var tabListenForFrame: Bool = false
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
/// changes in the list.
private var tabWindowsHash: Int = 0
/// This is set to false by init if the window managed by this controller should not be restorable.
/// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
/// This will be set to the initial frame of the window from the xib on load.
private var initialFrame: NSRect? = nil
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
@@ -71,12 +72,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// as the script. We may want to revisit this behavior when we have scrollback
// restoration.
self.restorable = (base?.command ?? "") == ""
// Setup our initial derived config based on the current app config
self.derivedConfig = DerivedConfig(ghostty.config)
super.init(ghostty, baseConfig: base, surfaceTree: tree)
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
@@ -104,6 +105,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
selector: #selector(onCloseOtherTabs),
name: .ghosttyCloseOtherTabs,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTabsOnTheRight),
name: .ghosttyCloseTabsOnTheRight,
object: nil)
center.addObserver(
self,
selector: #selector(onResetWindowSize),
@@ -128,46 +134,55 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
object: nil
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
}
// MARK: Base Controller Overrides
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
invalidateRestorableState()
// Update our zoom state
if let window = window as? TerminalWindow {
window.surfaceIsZoomed = to.zoomed != nil
}
// If our surface tree is now nil then we close our window.
if (to.isEmpty) {
self.window?.close()
}
}
override func fullscreenDidChange() {
super.fullscreenDidChange()
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
override func replaceSurfaceTree(
_ newTree: SplitTree<Ghostty.SurfaceView>,
moveFocusTo newView: Ghostty.SurfaceView? = nil,
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
undoAction: String? = nil
) {
// We have a special case if our tree is empty to close our tab immediately.
// This makes it so that undo is handled properly.
if newTree.isEmpty {
closeTabImmediately()
return
}
super.replaceSurfaceTree(
newTree,
moveFocusTo: newView,
moveFocusFrom: oldView,
undoAction: undoAction)
}
// MARK: Terminal Creation
@@ -190,7 +205,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
}
// The last controller to be main. We use this when paired with "preferredParent"
// to find the preferred window to attach new tabs, perform actions, etc. We
// always prefer the main window but if there isn't any (because we're triggered
@@ -280,6 +295,72 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return c
}
/// Create a new window with an existing split tree.
/// The window will be sized to match the tree's current view bounds if available.
/// - Parameters:
/// - ghostty: The Ghostty app instance.
/// - tree: The split tree to use for the new window.
/// - position: Optional screen position (top-left corner) for the new window.
/// If nil, the window will cascade from the last cascade point.
static func newWindow(
_ ghostty: Ghostty.App,
tree: SplitTree<Ghostty.SurfaceView>,
position: NSPoint? = nil,
confirmUndo: Bool = true,
) -> TerminalController {
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
// Calculate the target frame based on the tree's view bounds
let treeSize: CGSize? = tree.root?.viewBounds()
DispatchQueue.main.async {
if let window = c.window {
// If we have a tree size, resize the window's content to match
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
window.setContentSize(treeSize)
window.constrainToScreen()
}
if !window.styleMask.contains(.fullScreen) {
if let position {
window.setFrameTopLeftPoint(position)
window.constrainToScreen()
} else {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
}
}
c.showWindow(self)
}
// Setup our undo
if let undoManager = c.undoManager {
undoManager.setActionName("New Window")
undoManager.registerUndo(
withTarget: c,
expiresAfter: c.undoExpiration
) { target in
undoManager.disableUndoRegistration {
if confirmUndo {
target.closeWindow(nil)
} else {
target.closeWindowImmediately()
}
}
undoManager.registerUndo(
withTarget: ghostty,
expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newWindow(ghostty, tree: tree)
}
}
}
return c
}
static func newTab(
_ ghostty: Ghostty.App,
from parent: NSWindow? = nil,
@@ -402,7 +483,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return controller
}
//MARK: - Methods
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
@@ -425,15 +506,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return
}
// This is a surface-level config update. If we have the surface, we
// update our appearance based on it.
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(surfaceView) else { return }
// We can't use surfaceView.derivedConfig because it may not be updated
// yet since it also responds to notifications.
syncAppearance(.init(config))
/// Surface-level config will be updated in
/// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then
/// ``TerminalController/focusedSurfaceDidChange(to:)``
}
/// Update the accessory view of each tab according to the keyboard
@@ -489,6 +564,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
tabWindowsHash = v
self.relabelTabs()
}
override func syncAppearance() {
// When our focus changes, we update our window appearance based on the
// currently focused surface.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// Let our window handle its own appearance
@@ -518,13 +600,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
fromTopLeftOffsetX: CGFloat(x),
offsetY: CGFloat(y),
windowSize: frame.size)
// Clamp the origin to ensure the window stays fully visible on screen
var safeOrigin = origin
let vf = screen.visibleFrame
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)
// Return our new origin
var result = frame
result.origin = safeOrigin
@@ -552,14 +634,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeWindow(nil)
}
private func closeTabImmediately(registerRedo: Bool = true) {
func closeTabImmediately(registerRedo: Bool = true) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup,
tabGroup.windows.count > 1 else {
closeWindowImmediately()
return
}
// Undo
if let undoManager, let undoState {
// Register undo action to restore the tab
@@ -580,15 +662,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
}
window.close()
}
private func closeOtherTabsImmediately() {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard tabGroup.windows.count > 1 else { return }
// Start an undo grouping
if let undoManager {
undoManager.beginUndoGrouping()
@@ -596,7 +678,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
defer {
undoManager?.endUndoGrouping()
}
// Iterate through all tabs except the current one.
for window in tabGroup.windows where window != self.window {
// We ignore any non-terminal tabs. They don't currently exist and we can't
@@ -608,10 +690,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
controller.closeTabImmediately(registerRedo: false)
}
}
if let undoManager {
undoManager.setActionName("Close Other Tabs")
// We need to register an undo that refocuses this window. Otherwise, the
// undo operation above for each tab will steal focus.
undoManager.registerUndo(
@@ -621,7 +703,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil)
}
// Register redo action
undoManager.registerUndo(
withTarget: target,
@@ -633,9 +715,49 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
private func closeTabsOnTheRightImmediately() {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
guard !tabsToClose.isEmpty else { return }
undoManager?.beginUndoGrouping()
defer {
undoManager?.endUndoGrouping()
}
for (_, candidate) in tabsToClose {
if let controller = candidate.windowController as? TerminalController {
controller.closeTabImmediately(registerRedo: false)
}
}
if let undoManager {
undoManager.setActionName("Close Tabs to the Right")
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil)
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.closeTabsOnTheRightImmediately()
}
}
}
}
/// Closes the current window (including any other tabs) immediately and without
/// confirmation. This will setup proper undo state so the action can be undone.
private func closeWindowImmediately() {
func closeWindowImmediately() {
guard let window = window else { return }
registerUndoForCloseWindow()
@@ -707,7 +829,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
case (nil, nil): return true
}
}
// Find the index of the key window in our sorted states. This is a bit verbose
// but we only need this for this style of undo so we don't want to add it to
// UndoState.
@@ -733,12 +855,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let controllers = undoStates.map { undoState in
TerminalController(ghostty, with: undoState)
}
// The first controller becomes the parent window for all tabs.
// If we don't have a first controller (shouldn't be possible?)
// then we can't restore tabs.
guard let firstController = controllers.first else { return }
// Add all subsequent controllers as tabs to the first window
for controller in controllers.dropFirst() {
controller.showWindow(nil)
@@ -747,7 +869,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
firstWindow.addTabbedWindow(newWindow, ordered: .above)
}
}
// Make the appropriate window key. If we had a key window, restore it.
// Otherwise, make the last window key.
if let keyWindowIndex, keyWindowIndex < controllers.count {
@@ -813,6 +935,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let focusedSurface: UUID?
let tabIndex: Int?
weak var tabGroup: NSWindowTabGroup?
let tabColor: TerminalTabColor
}
convenience init(_ ghostty: Ghostty.App,
@@ -824,6 +947,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
showWindow(nil)
if let window {
window.setFrame(undoState.frame, display: true)
if let terminalWindow = window as? TerminalWindow {
terminalWindow.tabColor = undoState.tabColor
}
// If we have a tab group and index, restore the tab to its original position
if let tabGroup = undoState.tabGroup,
@@ -839,13 +965,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Make it the key window
window.makeKeyAndOrderFront(nil)
}
// Restore focus to the previously focused surface
if let focusedUUID = undoState.focusedSurface,
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
DispatchQueue.main.async {
Ghostty.moveFocus(to: focusTarget, from: nil)
}
} else if let focusedSurface = surfaceTree.first {
// No prior focused surface or we can't find it, let's focus
// the first.
self.focusedSurface = focusedSurface
DispatchQueue.main.async {
Ghostty.moveFocus(to: focusedSurface, from: nil)
}
}
}
}
@@ -859,7 +992,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
surfaceTree: surfaceTree,
focusedSurface: focusedSurface?.id,
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
tabGroup: window.tabGroup)
tabGroup: window.tabGroup,
tabColor: (window as? TerminalWindow)?.tabColor ?? .none)
}
//MARK: - NSWindowController
@@ -895,35 +1029,39 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
window.contentView = TerminalViewContainer(
ghostty: self.ghostty,
viewModel: self,
delegate: self,
))
)
// If we have a default size, we want to apply it.
if let defaultSize {
switch (defaultSize) {
case .frame:
// Frames can be applied immediately
defaultSize.apply(to: window)
case .contentIntrinsicSize:
// Content intrinsic size requires a short delay so that AppKit
// can layout our SwiftUI views.
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in
guard let window else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in
guard let self, let window else { return }
defaultSize.apply(to: window)
if let screen = window.screen ?? NSScreen.main {
let frame = self.adjustForWindowPosition(frame: window.frame, on: screen)
window.setFrameOrigin(frame.origin)
}
}
}
}
// Store our initial frame so we can know our default later. This MUST
// be after the defaultSize call above so that we don't re-apply our frame.
// Note: we probably want to set this on the first frame change or something
// so it respects cascade.
initialFrame = window.frame
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
@@ -1034,7 +1172,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if let window {
LastWindowPosition.shared.save(window)
}
// Remember our last main
Self.lastMain = self
}
@@ -1081,27 +1219,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
@IBAction func closeOtherTabs(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
// If we only have one window then we have no other tabs to close
guard tabGroup.windows.count > 1 else { return }
// Check if we have to confirm close.
guard tabGroup.windows.contains(where: { window in
// Ignore ourself
if window == self.window { return false }
// Ignore non-terminals
guard let controller = window.windowController as? TerminalController else {
return false
}
// Check if any surfaces require confirmation
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
}) else {
self.closeOtherTabsImmediately()
return
}
confirmClose(
messageText: "Close Other Tabs?",
informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed."
@@ -1110,6 +1248,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
@IBAction func closeTabsOnTheRight(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
guard !tabsToClose.isEmpty else { return }
let needsConfirm = tabsToClose.contains { (_, candidate) in
guard let controller = candidate.windowController as? TerminalController else {
return false
}
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
}
if !needsConfirm {
self.closeTabsOnTheRightImmediately()
return
}
confirmClose(
messageText: "Close Tabs on the Right?",
informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed."
) {
self.closeTabsOnTheRightImmediately()
}
}
@IBAction func returnToDefaultSize(_ sender: Any?) {
guard let window, let defaultSize else { return }
defaultSize.apply(to: window)
@@ -1151,7 +1318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
//MARK: - TerminalViewDelegate
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to)
@@ -1215,7 +1382,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Get our target window
let targetWindow = tabbedWindows[finalIndex]
// Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs.
// I believe this is due to messed up constraints for our hacky tab bar. I'd like to
// find a better workaround. For now, this improves things dramatically.
@@ -1228,7 +1395,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async {
selectedWindow.makeKey()
}
return
}
}
@@ -1311,6 +1478,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeOtherTabs(self)
}
@objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
closeTabsOnTheRight(self)
}
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
@@ -1373,23 +1546,28 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
extension TerminalController {
override func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(closeTabsOnTheRight):
guard let window, let tabGroup = window.tabGroup else { return false }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false }
return tabGroup.windows.enumerated().contains { $0.offset > currentIndex }
case #selector(returnToDefaultSize):
guard let window else { return false }
// Native fullscreen windows can't revert to default size.
if window.styleMask.contains(.fullScreen) {
return false
}
// If we're fullscreen at all then we can't change size
if fullscreenStyle?.isFullscreen ?? false {
return false
}
// If our window is already the default size or we don't have a
// default size, then disable.
return defaultSize?.isChanged(for: window) ?? false
default:
return super.validateMenuItem(item)
}
@@ -1405,10 +1583,10 @@ extension TerminalController {
enum DefaultSize {
/// A frame, set with `window.setFrame`
case frame(NSRect)
/// A content size, set with `window.setContentSize`
case contentIntrinsicSize
func isChanged(for window: NSWindow) -> Bool {
switch self {
case .frame(let rect):
@@ -1417,11 +1595,11 @@ extension TerminalController {
guard let view = window.contentView else {
return false
}
return view.frame.size != view.intrinsicContentSize
}
}
func apply(to window: NSWindow) {
switch self {
case .frame(let rect):
@@ -1430,13 +1608,13 @@ extension TerminalController {
guard let size = window.contentView?.intrinsicContentSize else {
return
}
window.setContentSize(size)
window.constrainToScreen()
}
}
}
private var defaultSize: DefaultSize? {
if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main {
// Maximize takes priority, we take up the full screen we're on.

View File

@@ -1,20 +1,22 @@
import Cocoa
/// The state stored for terminal window restoration.
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 5
protocol TerminalRestorable: Codable {
static var selfKey: String { get }
static var versionKey: String { get }
static var version: Int { get }
init(copy other: Self)
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let effectiveFullscreenMode: FullscreenMode?
/// Returns a base configuration to use when restoring terminal surfaces.
/// Override this to provide custom environment variables or other configuration.
var baseConfig: Ghostty.SurfaceConfiguration? { get }
}
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
}
extension TerminalRestorable {
static var selfKey: String { "state" }
static var versionKey: String { "version" }
/// Default implementation returns nil (no custom base config).
var baseConfig: Ghostty.SurfaceConfiguration? { nil }
init?(coder aDecoder: NSCoder) {
// If the version doesn't match then we can't decode. In the future we can perform
@@ -28,9 +30,7 @@ class TerminalRestorableState: Codable {
return nil
}
self.surfaceTree = v.value.surfaceTree
self.focusedSurface = v.value.focusedSurface
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
self.init(copy: v.value)
}
func encode(with coder: NSCoder) {
@@ -39,6 +39,33 @@ class TerminalRestorableState: Codable {
}
}
/// The state stored for terminal window restoration.
class TerminalRestorableState: TerminalRestorable {
class var version: Int { 7 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor
let titleOverride: String?
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
self.titleOverride = controller.titleOverride
}
required init(copy other: TerminalRestorableState) {
self.surfaceTree = other.surfaceTree
self.focusedSurface = other.focusedSurface
self.effectiveFullscreenMode = other.effectiveFullscreenMode
self.tabColor = other.tabColor
self.titleOverride = other.titleOverride
}
}
enum TerminalRestoreError: Error {
case delegateInvalid
case identifierUnknown
@@ -94,6 +121,12 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
return
}
// Restore our tab color
(window as? TerminalWindow)?.tabColor = state.tabColor
// Restore the tab title override
c.titleOverride = state.titleOverride
// Setup our restored state on the controller
// Find the focused surface in surfaceTree
if let focusedStr = state.focusedSurface {
@@ -158,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
}
}
}

View File

@@ -0,0 +1,185 @@
import AppKit
import SwiftUI
enum TerminalTabColor: Int, CaseIterable, Codable {
case none
case blue
case purple
case pink
case red
case orange
case yellow
case green
case teal
case graphite
var localizedName: String {
switch self {
case .none:
return "None"
case .blue:
return "Blue"
case .purple:
return "Purple"
case .pink:
return "Pink"
case .red:
return "Red"
case .orange:
return "Orange"
case .yellow:
return "Yellow"
case .green:
return "Green"
case .teal:
return "Teal"
case .graphite:
return "Graphite"
}
}
var displayColor: NSColor? {
switch self {
case .none:
return nil
case .blue:
return .systemBlue
case .purple:
return .systemPurple
case .pink:
return .systemPink
case .red:
return .systemRed
case .orange:
return .systemOrange
case .yellow:
return .systemYellow
case .green:
return .systemGreen
case .teal:
if #available(macOS 13.0, *) {
return .systemMint
} else {
return .systemTeal
}
case .graphite:
return .systemGray
}
}
func swatchImage(selected: Bool) -> NSImage {
let size = NSSize(width: 18, height: 18)
return NSImage(size: size, flipped: false) { rect in
let circleRect = rect.insetBy(dx: 1, dy: 1)
let circlePath = NSBezierPath(ovalIn: circleRect)
if let fillColor = self.displayColor {
fillColor.setFill()
circlePath.fill()
} else {
NSColor.clear.setFill()
circlePath.fill()
NSColor.quaternaryLabelColor.setStroke()
circlePath.lineWidth = 1
circlePath.stroke()
}
if self == .none {
let slash = NSBezierPath()
slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2))
slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2))
slash.lineWidth = 1.5
NSColor.secondaryLabelColor.setStroke()
slash.stroke()
}
if selected {
let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5))
highlight.lineWidth = 2
NSColor.controlAccentColor.setStroke()
highlight.stroke()
}
return true
}
}
}
// MARK: - Menu View
/// A SwiftUI view displaying a color palette for tab color selection.
/// Used as a custom view inside an NSMenuItem in the tab context menu.
struct TabColorMenuView: View {
@State private var currentSelection: TerminalTabColor
let onSelect: (TerminalTabColor) -> Void
init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) {
self._currentSelection = State(initialValue: selectedColor)
self.onSelect = onSelect
}
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("Tab Color")
.padding(.bottom, 2)
ForEach(Self.paletteRows, id: \.self) { row in
HStack(spacing: 2) {
ForEach(row, id: \.self) { color in
TabColorSwatch(
color: color,
isSelected: color == currentSelection
) {
currentSelection = color
onSelect(color)
}
}
}
}
}
.padding(.leading, Self.leadingPadding)
.padding(.trailing, 12)
.padding(.top, 4)
.padding(.bottom, 4)
}
static let paletteRows: [[TerminalTabColor]] = [
[.none, .blue, .purple, .pink, .red],
[.orange, .yellow, .green, .teal, .graphite],
]
/// Leading padding to align with the menu's icon gutter.
/// macOS 26 introduced icons in menus, requiring additional padding.
private static var leadingPadding: CGFloat {
if #available(macOS 26.0, *) {
return 40
} else {
return 12
}
}
}
/// A single color swatch button in the tab color palette.
private struct TabColorSwatch: View {
let color: TerminalTabColor
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Group {
if color == .none {
Image(systemName: isSelected ? "circle.slash" : "circle")
.foregroundStyle(.secondary)
} else if let displayColor = color.displayColor {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill")
.foregroundStyle(Color(nsColor: displayColor))
}
}
.font(.system(size: 16))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.help(color.localizedName)
}
}

View File

@@ -1,5 +1,6 @@
import SwiftUI
import GhosttyKit
import os
/// This delegate is notified of actions and property changes regarding the terminal view. This
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
@@ -16,9 +17,9 @@ protocol TerminalViewDelegate: AnyObject {
/// Perform an action. At the time of writing this is only triggered by the command palette.
func performAction(_ action: String, on: Ghostty.SurfaceView)
/// A split is resizing to a given value.
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
/// A split tree operation
func performSplitAction(_ action: TerminalSplitOperation)
}
/// The view model is a required implementation for TerminalView callers. This contains
@@ -81,7 +82,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
action: { delegate?.performSplitAction($0) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }

View File

@@ -0,0 +1,160 @@
import AppKit
import SwiftUI
/// Use this container to achieve a glass effect at the window level.
/// Modifying `NSThemeFrame` can sometimes be unpredictable.
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
private let terminalView: NSView
/// Glass effect view for liquid glass background when transparency is enabled
private var glassEffectView: NSView?
private var glassTopConstraint: NSLayoutConstraint?
private var derivedConfig: DerivedConfig
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
self.derivedConfig = DerivedConfig(config: ghostty.config)
self.terminalView = NSHostingView(rootView: TerminalView(
ghostty: ghostty,
viewModel: viewModel,
delegate: delegate
))
super.init(frame: .zero)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// To make ``TerminalController/DefaultSize/contentIntrinsicSize``
/// work in ``TerminalController/windowDidLoad()``,
/// we override this to provide the correct size.
override var intrinsicContentSize: NSSize {
terminalView.intrinsicContentSize
}
private func setup() {
addSubview(terminalView)
terminalView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
terminalView.topAnchor.constraint(equalTo: topAnchor),
terminalView.leadingAnchor.constraint(equalTo: leadingAnchor),
terminalView.bottomAnchor.constraint(equalTo: bottomAnchor),
terminalView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
updateGlassEffectIfNeeded()
updateGlassEffectTopInsetIfNeeded()
}
override func layout() {
super.layout()
updateGlassEffectTopInsetIfNeeded()
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
let newValue = DerivedConfig(config: config)
guard newValue != derivedConfig else { return }
derivedConfig = newValue
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
}
}
// MARK: Glass
private extension TerminalViewContainer {
#if compiler(>=6.2)
@available(macOS 26.0, *)
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
if let existed = glassEffectView as? NSGlassEffectView {
updateGlassEffectTopInsetIfNeeded()
return existed
}
guard let themeFrameView = window?.contentView?.superview else {
return nil
}
let effectView = NSGlassEffectView()
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),
])
}
glassEffectView = effectView
return effectView
}
#endif // compiler(>=6.2)
func updateGlassEffectIfNeeded() {
#if compiler(>=6.2)
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
glassTopConstraint = nil
return
}
guard let effectView = addGlassEffectViewIfNeeded() else {
return
}
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
default:
break
}
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
}
#endif // compiler(>=6.2)
}
func updateGlassEffectTopInsetIfNeeded() {
#if compiler(>=6.2)
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
return
}
guard glassEffectView != nil else { return }
guard let themeFrameView = window?.contentView?.superview else { return }
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
#endif // compiler(>=6.2)
}
struct DerivedConfig: Equatable {
var backgroundOpacity: Double = 0
var backgroundBlur: Ghostty.Config.BackgroundBlur
var backgroundColor: Color = .clear
init(config: Ghostty.Config) {
self.backgroundBlur = config.backgroundBlur
self.backgroundOpacity = config.backgroundOpacity
self.backgroundColor = config.backgroundColor
}
}
}

View File

@@ -7,10 +7,10 @@ import GhosttyKit
class TerminalWindow: NSWindow {
/// Posted when a terminal window awakes from nib.
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
/// Posted when a terminal window will close
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
/// This is the key in UserDefaults to use for the default `level` value. This is
/// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel"
@@ -20,13 +20,23 @@ class TerminalWindow: NSWindow {
/// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
/// Update notification UI in titlebar
private let updateAccessory = NSTitlebarAccessoryViewController()
/// Visual indicator that mirrors the selected tab color.
private lazy var tabColorIndicator: NSHostingView<TabColorIndicatorView> = {
let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor))
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
/// Sets up our tab context menu
private var tabMenuObserver: NSObjectProtocol? = nil
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
@@ -34,11 +44,24 @@ class TerminalWindow: NSWindow {
true
}
/// Glass effect view for liquid glass background when transparency is enabled
private var glassEffectView: NSView?
/// Gets the terminal controller from the window controller.
var terminalController: TerminalController? {
windowController as? TerminalController
}
/// The color assigned to this window's tab. Setting this updates the tab color indicator
/// and marks the window's restorable state as dirty.
var tabColor: TerminalTabColor = .none {
didSet {
guard tabColor != oldValue else { return }
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
invalidateRestorableState()
}
}
// MARK: NSWindow Overrides
override var toolbar: NSToolbar? {
@@ -53,7 +76,18 @@ class TerminalWindow: NSWindow {
override func awakeFromNib() {
// Notify that this terminal window has loaded
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
// This is fragile, but there doesn't seem to be an official API for customizing
// native tab bar menus.
tabMenuObserver = NotificationCenter.default.addObserver(
forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"),
object: nil,
queue: .main
) { [weak self] n in
guard let self, let menu = n.object as? NSMenu else { return }
self.configureTabContextMenuIfNeeded(menu)
}
// This is required so that window restoration properly creates our tabs
// again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows.
@@ -61,14 +95,14 @@ class TerminalWindow: NSWindow {
DispatchQueue.main.async {
self.tabbingMode = .automatic
}
// All new windows are based on the app config at the time of creation.
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
let config = appDelegate.ghostty.config
// Setup our initial config
derivedConfig = .init(config)
// If there is a hardcoded title in the configuration, we set that
// immediately. Future `set_title` apprt actions will override this
// if necessary but this ensures our window loads with the proper
@@ -103,7 +137,7 @@ class TerminalWindow: NSWindow {
}))
addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
// Create update notification accessory
if supportsUpdateAccessory {
updateAccessory.layoutAttribute = .right
@@ -119,9 +153,16 @@ class TerminalWindow: NSWindow {
// Setup the accessory view for tabs that shows our keyboard shortcuts,
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
// where buttons were not clickable.
let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
let stackView = NSStackView()
stackView.orientation = .horizontal
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
stackView.spacing = 3
stackView.spacing = 4
stackView.alignment = .centerY
stackView.addArrangedSubview(tabColorIndicator)
stackView.addArrangedSubview(keyEquivalentLabel)
stackView.addArrangedSubview(resetZoomTabButton)
tab.accessoryView = stackView
// Get our saved level
@@ -132,7 +173,7 @@ class TerminalWindow: NSWindow {
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
override func close() {
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
super.close()
@@ -153,7 +194,7 @@ class TerminalWindow: NSWindow {
// Its possible we miss the accessory titlebar call so we check again
// whenever the window becomes main. Both of these are idempotent.
if hasTabBar {
if tabBarView != nil {
tabBarDidAppear()
} else {
tabBarDidDisappear()
@@ -202,31 +243,6 @@ class TerminalWindow: NSWindow {
/// added.
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
func findTitlebarView() -> NSView? {
// Find our tab bar. If it doesn't exist we don't do anything.
//
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
guard let themeFrameView = contentView?.rootView else { return nil }
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
themeFrameView.value(forKey: "titlebarView") as? NSView
} else {
NSView?.none
}
return titlebarView
}
func findTabBar() -> NSView? {
findTitlebarView()?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns true if there is a tab bar visible on this window.
var hasTabBar: Bool {
findTabBar() != nil
}
var hasMoreThanOneTabs: Bool {
/// accessing ``tabGroup?.windows`` here
/// will cause other edge cases, be careful
@@ -264,7 +280,7 @@ class TerminalWindow: NSWindow {
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
removeTitlebarAccessoryViewController(at: idx)
}
// We don't need to do this with the update accessory. I don't know why but
// everything works fine.
}
@@ -419,6 +435,7 @@ class TerminalWindow: NSWindow {
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard isVisible else { return }
defer { updateColorSchemeForSurfaceTree() }
// Basic properties
appearance = surfaceConfig.windowAppearance
@@ -427,8 +444,12 @@ class TerminalWindow: NSWindow {
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
//
// Also check if the user has overridden transparency to be fully opaque.
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
if !styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
!forceOpaque &&
(surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle)
{
isOpaque = false
@@ -437,7 +458,8 @@ class TerminalWindow: NSWindow {
// Terminal.app more easily.
backgroundColor = .white.withAlphaComponent(0.001)
if let appDelegate = NSApp.delegate as? AppDelegate {
// We don't need to set blur when using glass
if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate {
ghostty_set_window_background_blur(
appDelegate.ghostty.app,
Unmanaged.passUnretained(self).toOpaque())
@@ -481,9 +503,13 @@ class TerminalWindow: NSWindow {
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
}
func updateColorSchemeForSurfaceTree() {
terminalController?.updateColorSchemeForSurfaceTree()
}
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
// If we don't have an X/Y then we try to use the previously saved window pos.
guard let x, let y else {
guard x != nil, y != nil else {
if (!LastWindowPosition.shared.restore(self)) {
center()
}
@@ -502,7 +528,7 @@ class TerminalWindow: NSWindow {
center()
return
}
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
setFrameOrigin(frame.origin)
}
@@ -512,20 +538,32 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
deinit {
if let observer = tabMenuObserver {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: Config
struct DerivedConfig {
let title: String?
let backgroundBlur: Ghostty.Config.BackgroundBlur
let backgroundColor: NSColor
let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: String
let windowCornerRadius: CGFloat
init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1
self.macosWindowButtons = .visible
self.backgroundBlur = .disabled
self.macosTitlebarStyle = "transparent"
self.windowCornerRadius = 16
}
init(_ config: Ghostty.Config) {
@@ -533,6 +571,18 @@ class TerminalWindow: NSWindow {
self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons
self.backgroundBlur = config.backgroundBlur
self.macosTitlebarStyle = config.macosTitlebarStyle
// Set corner radius based on macos-titlebar-style
// Native, transparent, and hidden styles use 16pt radius
// Tabs style uses 20pt radius
switch config.macosTitlebarStyle {
case "tabs":
self.windowCornerRadius = 20
default:
self.windowCornerRadius = 16
}
}
}
}
@@ -579,12 +629,12 @@ extension TerminalWindow {
}
}
}
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdateAccessoryView: View {
@ObservedObject var viewModel: ViewModel
@ObservedObject var model: UpdateViewModel
var body: some View {
// We use the same top/trailing padding so that it hugs the same.
UpdatePill(model: model)
@@ -594,3 +644,120 @@ extension TerminalWindow {
}
}
/// A small circle indicator displayed in the tab accessory view that shows
/// the user-assigned tab color. When no color is set, the view is hidden.
private struct TabColorIndicatorView: View {
/// The tab color to display.
let tabColor: TerminalTabColor
var body: some View {
if let color = tabColor.displayColor {
Circle()
.fill(Color(color))
.frame(width: 6, height: 6)
} else {
Circle()
.fill(Color.clear)
.frame(width: 6, height: 6)
.hidden()
}
}
}
// MARK: - Tab Context Menu
extension TerminalWindow {
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem")
private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator")
private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette")
func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
guard isTabContextMenu(menu) else { return }
// Get the target from an existing menu item. The native tab context menu items
// target the specific window/controller that was right-clicked, not the focused one.
// We need to use that same target so validation and action use the correct tab.
let targetController = menu.items
.first { $0.action == NSSelectorFromString("performClose:") }
.flatMap { $0.target as? NSWindow }
.flatMap { $0.windowController as? TerminalController }
// Close tabs to the right
let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "")
item.identifier = Self.closeTabsOnRightMenuItemIdentifier
item.target = targetController
item.setImageIfDesired(systemSymbolName: "xmark")
if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil,
menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil {
menu.addItem(item)
}
// Other close items should have the xmark to match Safari on macOS 26
for menuItem in menu.items {
if menuItem.action == NSSelectorFromString("performClose:") ||
menuItem.action == NSSelectorFromString("performCloseOtherTabs:") {
menuItem.setImageIfDesired(systemSymbolName: "xmark")
}
}
appendTabModifierSection(to: menu, target: targetController)
}
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These selectors must all exist for it to be a tab context menu.
let requiredSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
"toggleTabOverview:"
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return requiredSelectors.isSubset(of: selectorNames)
}
private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) {
menu.removeItems(withIdentifiers: [
Self.tabColorSeparatorIdentifier,
Self.changeTitleMenuItemIdentifier,
Self.tabColorPaletteIdentifier
])
let separator = NSMenuItem.separator()
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
// Change Title...
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = target
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
menu.addItem(changeTitleItem)
let paletteItem = NSMenuItem()
paletteItem.identifier = Self.tabColorPaletteIdentifier
paletteItem.view = makeTabColorPaletteView(
selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none
) { [weak target] color in
(target?.window as? TerminalWindow)?.tabColor = color
}
menu.addItem(paletteItem)
}
}
private func makeTabColorPaletteView(
selectedColor: TerminalTabColor,
selectionHandler: @escaping (TerminalTabColor) -> Void
) -> NSView {
let hostingView = NSHostingView(rootView: TabColorMenuView(
selectedColor: selectedColor,
onSelect: selectionHandler
))
hostingView.frame.size = hostingView.intrinsicContentSize
return hostingView
}

View File

@@ -67,6 +67,38 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
viewModel.isMainWindow = false
}
/// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work
/// naturally due to whatever mess we made.
override func sendEvent(_ event: NSEvent) {
guard viewModel.hasTabBar else {
super.sendEvent(event)
return
}
let isRightClick =
event.type == .rightMouseDown ||
(event.type == .otherMouseDown && event.buttonNumber == 2) ||
(event.type == .leftMouseDown && event.modifierFlags.contains(.control))
guard isRightClick else {
super.sendEvent(event)
return
}
guard let tabBarView else {
super.sendEvent(event)
return
}
let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else {
super.sendEvent(event)
return
}
tabBarView.rightMouseDown(with: event)
}
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
// this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
@@ -144,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
guard tabBarObserver == nil else { return }
guard
let titlebarView = findTitlebarView(),
let tabBar = findTabBar()
let titlebarView,
let tabBarView = self.tabBarView
else { return }
// View model updates must happen on their own ticks.
@@ -154,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
}
// Find our clip view
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
guard let accessoryView = clipView.subviews[safe: 0] else { return }
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
// Make sure tabBar's height won't be stretched
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
tabBar.frame.size.height = newTabButton.frame.width
tabBarView.frame.size.height = newTabButton.frame.width
// The container is the view that we'll constrain our tab bar within.
let container = toolbarView
@@ -196,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// other events occur, the tab bar can resize and clear our constraints. When this
// happens, we need to remove our custom constraints and re-apply them once the
// tab bar has proper dimensions again to avoid constraint conflicts.
tabBar.postsFrameChangedNotifications = true
tabBarView.postsFrameChangedNotifications = true
tabBarObserver = NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: tabBar,
object: tabBarView,
queue: .main
) { [weak self] _ in
guard let self else { return }
@@ -290,7 +322,8 @@ extension TitlebarTabsTahoeTerminalWindow {
} else {
// 1x1.gif strikes again! For real: if we render a zero-sized
// view here then the toolbar just disappears our view. I don't
// know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
// know. On macOS 26.1+ the view no longer disappears, but the
// toolbar still logs an ambiguous content size warning.
Color.clear.frame(width: 1, height: 1)
}
}

View File

@@ -5,7 +5,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
/// Titlebar tabs can't support the update accessory because of the way we layout
/// the native tabs back into the menu bar.
override var supportsUpdateAccessory: Bool { false }
/// This is used to determine if certain elements should be drawn light or dark and should
/// be updated whenever the window background color or surrounding elements changes.
fileprivate var isLightTheme: Bool = false
@@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
// Hide the window drag handle.
windowDragHandle?.isHidden = true
// Reenable the main toolbar title
// Re-enable the main toolbar title
if let toolbar = toolbar as? TerminalToolbar {
toolbar.titleIsHidden = false
}

View File

@@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
/// This is necessary because various macOS operations (tab switching, tab bar
/// visibility changes) can reset the titlebar appearance.
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
/// KVO observation for tab group window changes.
private var tabGroupWindowsObservation: NSKeyValueObservation?
private var tabBarVisibleObservation: NSKeyValueObservation?
deinit {
tabGroupWindowsObservation?.invalidate()
tabBarVisibleObservation?.invalidate()
}
// MARK: NSWindow
override func awakeFromNib() {
@@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
override func becomeMain() {
super.becomeMain()
guard let lastSurfaceConfig else { return }
syncAppearance(lastSurfaceConfig)
@@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
}
}
}
override func update() {
super.update()
@@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Save our config in case we need to reapply
lastSurfaceConfig = surfaceConfig
// Everytime we change appearance, set KVO up again in case any of our
// Every time we change appearance, set KVO up again in case any of our
// references changed (e.g. tabGroup is new).
setupKVO()
@@ -88,9 +88,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// color of the titlebar in native fullscreen view.
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
titlebarView.wantsLayer = true
titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor
// For glass background styles, use a transparent titlebar to let the glass effect show through
// Only apply this for transparent and tabs titlebar styles
let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle
let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" ||
derivedConfig.macosTitlebarStyle == "tabs"
titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar)
? NSColor.clear.cgColor
: preferredBackgroundColor?.cgColor
}
// In all cases, we have to hide the background view since this has multiple subviews
// that force a background color.
titlebarBackgroundView?.isHidden = true
@@ -99,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
@available(macOS 13.0, *)
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
guard let titlebarContainer else { return }
// Setup the titlebar background color to match ours
titlebarContainer.wantsLayer = true
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
// See the docs for the function that sets this to true on why
effectViewIsHidden = false
// Necessary to not draw the border around the title
titlebarAppearsTransparent = true
}
@@ -132,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Remove existing observation if any
tabGroupWindowsObservation?.invalidate()
tabGroupWindowsObservation = nil
// Check if tabGroup is available
guard let tabGroup else { return }
@@ -161,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// Remove existing observation if any
tabBarVisibleObservation?.invalidate()
tabBarVisibleObservation = nil
// Set up KVO observation for isTabBarVisible
tabBarVisibleObservation = tabGroup?.observe(
\.isTabBarVisible,
@@ -172,18 +181,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
self.syncAppearance(lastSurfaceConfig)
}
}
// MARK: macOS 13 to 15
// We only need to set this once, but need to do it after the window has been created in order
// to determine if the theme is using a very dark background, in which case we don't want to
// remove the effect view if the default tab bar is being used since the effect created in
// `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
private var effectViewIsHidden = false
private func hideEffectView() {
guard !effectViewIsHidden else { return }
// By hiding the visual effect view, we allow the window's (or titlebar's in this case)
// background color to show through. If we were to set `titlebarAppearsTransparent` to true
// the selected tab would look fine, but the unselected ones and new tab button backgrounds

View File

@@ -127,6 +127,41 @@ extension Ghostty.Action {
}
}
}
enum PromptTitle {
case surface
case tab
init(_ c: ghostty_action_prompt_title_e) {
switch c {
case GHOSTTY_PROMPT_TITLE_TAB:
self = .tab
default:
self = .surface
}
}
}
enum KeyTable {
case activate(name: String)
case deactivate
case deactivateAll
init?(c: ghostty_action_key_table_s) {
switch c.tag {
case GHOSTTY_KEY_TABLE_ACTIVATE:
let data = Data(bytes: c.value.activate.name, count: c.value.activate.len)
let name = String(data: data, encoding: .utf8) ?? ""
self = .activate(name: name)
case GHOSTTY_KEY_TABLE_DEACTIVATE:
self = .deactivate
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
self = .deactivateAll
default:
return nil
}
}
}
}
// Putting the initializer in an extension preserves the automatic one.

View File

@@ -29,6 +29,8 @@ extension Ghostty {
/// configuration (i.e. font size) from the previously focused window. This would override this.
@Published private(set) var config: Config
/// Preferred config file than the default ones
private var configPath: String?
/// The ghostty app instance. We only have one of these for the entire app, although I guess
/// in theory you can have multiple... I don't know why you would...
@Published var app: ghostty_app_t? = nil {
@@ -44,9 +46,10 @@ extension Ghostty {
return ghostty_app_needs_confirm_quit(app)
}
init() {
init(configPath: String? = nil) {
self.configPath = configPath
// Initialize the global configuration.
self.config = Config()
self.config = Config(at: configPath)
if self.config.config == nil {
readiness = .error
return
@@ -143,7 +146,7 @@ extension Ghostty {
}
// Hard or full updates have to reload the full configuration
let newConfig = Config()
let newConfig = Config(at: configPath)
guard newConfig.loaded else {
Ghostty.logger.warning("failed to reload configuration")
return
@@ -163,7 +166,7 @@ extension Ghostty {
// Hard or full updates have to reload the full configuration.
// NOTE: We never set this on self.config because this is a surface-only
// config. We free it after the call.
let newConfig = Config()
let newConfig = Config(at: configPath)
guard newConfig.loaded else {
Ghostty.logger.warning("failed to reload configuration")
return
@@ -501,14 +504,17 @@ extension Ghostty {
case GHOSTTY_ACTION_GOTO_SPLIT:
return gotoSplit(app, target: target, direction: action.action.goto_split)
case GHOSTTY_ACTION_GOTO_WINDOW:
return gotoWindow(app, target: target, direction: action.action.goto_window)
case GHOSTTY_ACTION_RESIZE_SPLIT:
resizeSplit(app, target: target, resize: action.action.resize_split)
return resizeSplit(app, target: target, resize: action.action.resize_split)
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
equalizeSplits(app, target: target)
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
toggleSplitZoom(app, target: target)
return toggleSplitZoom(app, target: target)
case GHOSTTY_ACTION_INSPECTOR:
controlInspector(app, target: target, mode: action.action.inspector)
@@ -523,7 +529,7 @@ extension Ghostty {
setTitle(app, target: target, v: action.action.set_title)
case GHOSTTY_ACTION_PROMPT_TITLE:
return promptTitle(app, target: target)
return promptTitle(app, target: target, v: action.action.prompt_title)
case GHOSTTY_ACTION_PWD:
pwdChanged(app, target: target, v: action.action.pwd)
@@ -570,9 +576,15 @@ extension Ghostty {
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
toggleVisibility(app, target: target)
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
toggleBackgroundOpacity(app, target: target)
case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_KEY_TABLE:
keyTable(app, target: target, v: action.action.key_table)
case GHOSTTY_ACTION_PROGRESS_REPORT:
progressReport(app, target: target, v: action.action.progress_report)
@@ -588,6 +600,9 @@ extension Ghostty {
case GHOSTTY_ACTION_RING_BELL:
ringBell(app, target: target)
case GHOSTTY_ACTION_READONLY:
setReadonly(app, target: target, v: action.action.readonly)
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app)
@@ -618,12 +633,13 @@ extension Ghostty {
case GHOSTTY_ACTION_SEARCH_SELECTED:
searchSelected(app, target: target, v: action.action.search_selected)
case GHOSTTY_ACTION_PRESENT_TERMINAL:
return presentTerminal(app, target: target)
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
fallthrough
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
fallthrough
case GHOSTTY_ACTION_PRESENT_TERMINAL:
fallthrough
case GHOSTTY_ACTION_SIZE_LIMIT:
fallthrough
case GHOSTTY_ACTION_QUIT_TIMER:
@@ -760,7 +776,7 @@ extension Ghostty {
name: Notification.ghosttyNewWindow,
object: surfaceView,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW)),
]
)
@@ -797,7 +813,7 @@ extension Ghostty {
name: Notification.ghosttyNewTab,
object: surfaceView,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB)),
]
)
@@ -826,7 +842,7 @@ extension Ghostty {
object: surfaceView,
userInfo: [
"direction": direction,
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)),
]
)
@@ -836,6 +852,30 @@ extension Ghostty {
}
}
private static func presentTerminal(
_ app: ghostty_app_t,
target: ghostty_target_s
) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
NotificationCenter.default.post(
name: Notification.ghosttyPresentTerminal,
object: surfaceView
)
return true
default:
assertionFailure()
return false
}
}
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
@@ -861,6 +901,13 @@ extension Ghostty {
)
return
case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT:
NotificationCenter.default.post(
name: .ghosttyCloseTabsOnTheRight,
object: surfaceView
)
return
default:
assertionFailure()
}
@@ -1003,6 +1050,31 @@ extension Ghostty {
}
}
private static func setReadonly(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_readonly_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set readonly does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyDidChangeReadonly,
object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON,
]
)
default:
assertionFailure()
}
}
private static func moveTab(
_ app: ghostty_app_t,
target: ghostty_target_s,
@@ -1114,19 +1186,82 @@ extension Ghostty {
}
}
private static func gotoWindow(
_ app: ghostty_app_t,
target: ghostty_target_s,
direction: ghostty_action_goto_window_e
) -> Bool {
// Collect candidate windows: visible terminal windows that are either
// standalone or the currently selected tab in their tab group. This
// treats each native tab group as a single "window" for navigation
// purposes, since goto_tab handles per-tab navigation.
let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in
guard window.windowController is BaseTerminalController else { return false }
guard window.isVisible, !window.isMiniaturized else { return false }
// For native tabs, only include the selected tab in each group
if let group = window.tabGroup, group.selectedWindow !== window {
return false
}
return true
}
// Need at least two windows to navigate between
guard candidates.count > 1 else { return false }
// Find starting index from the current key/main window
let startIndex = candidates.firstIndex(where: { $0.isKeyWindow })
?? candidates.firstIndex(where: { $0.isMainWindow })
?? 0
let step: Int
switch direction {
case GHOSTTY_GOTO_WINDOW_NEXT:
step = 1
case GHOSTTY_GOTO_WINDOW_PREVIOUS:
step = -1
default:
return false
}
// Iterate with wrap-around until we find a valid window or return to start
let count = candidates.count
var index = (startIndex + step + count) % count
while index != startIndex {
let candidate = candidates[index]
if candidate.isVisible, !candidate.isMiniaturized {
candidate.makeKeyAndOrderFront(nil)
// Also focus the terminal surface within the window
if let controller = candidate.windowController as? BaseTerminalController,
let surface = controller.focusedSurface {
Ghostty.moveFocus(to: surface)
}
return true
}
index = (index + step + count) % count
}
return false
}
private static func resizeSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
resize: ghostty_action_resize_split_s) {
resize: ghostty_action_resize_split_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("resize split does nothing with an app target")
return
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return }
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
// If the window has no splits, the action is not performable
guard controller.surfaceTree.isSplit else { return false }
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false }
NotificationCenter.default.post(
name: Notification.didResizeSplit,
object: surfaceView,
@@ -1135,9 +1270,11 @@ extension Ghostty {
Notification.ResizeSplitAmountKey: resize.amount,
]
)
return true
default:
assertionFailure()
return false
}
}
@@ -1165,23 +1302,30 @@ extension Ghostty {
private static func toggleSplitZoom(
_ app: ghostty_app_t,
target: ghostty_target_s) {
target: ghostty_target_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
return
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
// If the window has no splits, the action is not performable
guard controller.surfaceTree.isSplit else { return false }
NotificationCenter.default.post(
name: Notification.didToggleSplitZoom,
object: surfaceView
)
return true
default:
assertionFailure()
return false
}
}
@@ -1279,6 +1423,27 @@ extension Ghostty {
}
}
private static func toggleBackgroundOpacity(
_ app: ghostty_app_t,
target: ghostty_target_s
) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle background opacity does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface,
let surfaceView = self.surfaceView(from: surface),
let controller = surfaceView.window?.windowController as? BaseTerminalController else { return }
controller.toggleBackgroundOpacity()
default:
assertionFailure()
}
}
private static func toggleSecureInput(
_ app: ghostty_app_t,
target: ghostty_target_s,
@@ -1343,22 +1508,50 @@ extension Ghostty {
private static func promptTitle(
_ app: ghostty_app_t,
target: ghostty_target_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set title prompt does nothing with an app target")
return false
target: ghostty_target_s,
v: ghostty_action_prompt_title_e) -> Bool {
let promptTitle = Action.PromptTitle(v)
switch promptTitle {
case .surface:
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set title prompt does nothing with an app target")
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
surfaceView.promptTitle()
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
surfaceView.promptTitle()
return true
default:
assertionFailure()
default:
assertionFailure()
return false
}
case .tab:
switch (target.tag) {
case GHOSTTY_TARGET_APP:
guard let window = NSApp.mainWindow ?? NSApp.keyWindow,
let controller = window.windowController as? BaseTerminalController
else { return false }
controller.promptTabTitle()
return true
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let window = surfaceView.window,
let controller = window.windowController as? BaseTerminalController
else { return false }
controller.promptTabTitle()
return true
default:
assertionFailure()
return false
}
}
return true
}
private static func pwdChanged(
@@ -1598,7 +1791,32 @@ extension Ghostty {
assertionFailure()
}
}
private static func keyTable(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_key_table_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("key table does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let action = Ghostty.Action.KeyTable(c: v) else { return }
NotificationCenter.default.post(
name: Notification.didChangeKeyTable,
object: surfaceView,
userInfo: [Notification.KeyTableKey: action]
)
default:
assertionFailure()
}
}
private static func progressReport(
_ app: ghostty_app_t,
target: ghostty_target_s,
@@ -1668,11 +1886,15 @@ extension Ghostty {
let startSearch = Ghostty.Action.StartSearch(c: v)
DispatchQueue.main.async {
if surfaceView.searchState != nil {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
if let searchState = surfaceView.searchState {
if let needle = startSearch.needle, !needle.isEmpty {
searchState.needle = needle
}
} else {
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
}
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
}
default:

View File

@@ -3,28 +3,18 @@ import GhosttyKit
extension Ghostty {
/// `ghostty_command_s`
struct Command: Sendable {
private let cValue: ghostty_command_s
/// The title of the command.
var title: String {
String(cString: cValue.title)
}
let title: String
/// Human-friendly description of what this command will do.
var description: String {
String(cString: cValue.description)
}
let description: String
/// The full action that must be performed to invoke this command.
var action: String {
String(cString: cValue.action)
}
let action: String
/// Only the key portion of the action so you can compare action types, e.g. `goto_split`
/// instead of `goto_split:left`.
var actionKey: String {
String(cString: cValue.action_key)
}
let actionKey: String
/// True if this can be performed on this target.
var isSupported: Bool {
@@ -40,7 +30,10 @@ extension Ghostty {
]
init(cValue: ghostty_command_s) {
self.cValue = cValue
self.title = String(cString: cValue.title)
self.description = String(cString: cValue.description)
self.action = String(cString: cValue.action)
self.actionKey = String(cString: cValue.action_key)
}
}
}

View File

@@ -33,14 +33,16 @@ extension Ghostty {
return diags
}
init() {
if let cfg = Self.loadConfig() {
self.config = cfg
}
init(config: ghostty_config_t?) {
self.config = config
}
init(clone config: ghostty_config_t) {
self.config = ghostty_config_clone(config)
convenience init(at path: String? = nil, finalize: Bool = true) {
self.init(config: Self.loadConfig(at: path, finalize: finalize))
}
convenience init(clone config: ghostty_config_t) {
self.init(config: ghostty_config_clone(config))
}
deinit {
@@ -48,7 +50,10 @@ extension Ghostty {
}
/// Initializes a new configuration and loads all the values.
static private func loadConfig() -> ghostty_config_t? {
/// - Parameters:
/// - path: An optional preferred config file path. Pass `nil` to load the default configuration files.
/// - finalize: Whether to finalize the configuration to populate default values.
static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? {
// Initialize the global configuration.
guard let cfg = ghostty_config_new() else {
logger.critical("ghostty_config_new failed")
@@ -59,7 +64,11 @@ extension Ghostty {
// We only do this on macOS because other Apple platforms do not have the
// same filesystem concept.
#if os(macOS)
ghostty_config_load_default_files(cfg);
if let path {
ghostty_config_load_file(cfg, path)
} else {
ghostty_config_load_default_files(cfg)
}
// We only load CLI args when not running in Xcode because in Xcode we
// pass some special parameters to control the debugger.
@@ -74,9 +83,10 @@ extension Ghostty {
// have to do this synchronously. When we support config updating we can do
// this async and update later.
// Finalize will make our defaults available.
ghostty_config_finalize(cfg)
if finalize {
// Finalize will make our defaults available.
ghostty_config_finalize(cfg)
}
// Log any configuration errors. These will be automatically shown in a
// pop-up window too.
let diagsCount = ghostty_config_diagnostics_count(cfg)
@@ -124,6 +134,14 @@ extension Ghostty {
return .init(rawValue: v)
}
var splitPreserveZoom: SplitPreserveZoom {
guard let config = self.config else { return .init() }
var v: CUnsignedInt = 0
let key = "split-preserve-zoom"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() }
return .init(rawValue: v)
}
var initialWindow: Bool {
guard let config = self.config else { return true }
var v = true;
@@ -402,12 +420,12 @@ extension Ghostty {
return v;
}
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
var backgroundBlur: BackgroundBlur {
guard let config = self.config else { return .disabled }
var v: Int16 = 0
let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v;
return BackgroundBlur(fromCValue: v)
}
var unfocusedSplitOpacity: Double {
@@ -614,6 +632,16 @@ extension Ghostty {
let str = String(cString: ptr)
return Scrollbar(rawValue: str) ?? defaultValue
}
var commandPaletteEntries: [Ghostty.Command] {
guard let config = self.config else { return [] }
var v: ghostty_config_command_list_s = .init()
let key = "command-palette-entry"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] }
guard v.len > 0 else { return [] }
let buffer = UnsafeBufferPointer(start: v.commands, count: v.len)
return buffer.map { Ghostty.Command(cValue: $0) }
}
}
}
@@ -626,6 +654,68 @@ extension Ghostty.Config {
case download
}
/// Background blur configuration that maps from the C API values.
/// Positive values represent blur radius, special negative values
/// represent macOS-specific glass effects.
enum BackgroundBlur: Equatable {
case disabled
case radius(Int)
case macosGlassRegular
case macosGlassClear
init(fromCValue value: Int16) {
switch value {
case 0:
self = .disabled
case -1:
if #available(macOS 26.0, *) {
self = .macosGlassRegular
} else {
self = .disabled
}
case -2:
if #available(macOS 26.0, *) {
self = .macosGlassClear
} else {
self = .disabled
}
default:
self = .radius(Int(value))
}
}
var isEnabled: Bool {
switch self {
case .disabled:
return false
default:
return true
}
}
/// Returns true if this is a macOS glass style (regular or clear).
var isGlassStyle: Bool {
switch self {
case .macosGlassRegular, .macosGlassClear:
return true
default:
return false
}
}
/// Returns the blur radius if applicable, nil for glass effects.
var radius: Int? {
switch self {
case .disabled:
return nil
case .radius(let r):
return r
case .macosGlassRegular, .macosGlassClear:
return nil
}
}
}
struct BellFeatures: OptionSet {
let rawValue: CUnsignedInt
@@ -635,6 +725,12 @@ extension Ghostty.Config {
static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
}
struct SplitPreserveZoom: OptionSet {
let rawValue: CUnsignedInt
static let navigation = SplitPreserveZoom(rawValue: 1 << 0)
}
enum MacDockDropBehavior: String {
case new_tab = "new-tab"

View File

@@ -32,6 +32,10 @@ extension Ghostty {
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
key = KeyEquivalent(Character(scalar))
case GHOSTTY_TRIGGER_CATCH_ALL:
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
return nil
default:
return nil
}
@@ -64,7 +68,7 @@ extension Ghostty {
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
// Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but thats okay -- we don't use that information.
// Ghostty structure but that's okay -- we don't use that information.
let rawFlags = flags.rawValue
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
@@ -96,6 +100,32 @@ extension Ghostty {
]
}
// MARK: Ghostty.Input.BindingFlags
extension Ghostty.Input {
/// `ghostty_binding_flags_e`
struct BindingFlags: OptionSet, Sendable {
let rawValue: UInt32
static let consumed = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue)
static let all = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_ALL.rawValue)
static let global = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_GLOBAL.rawValue)
static let performable = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue)
init(rawValue: UInt32) {
self.rawValue = rawValue
}
init(cFlags: ghostty_binding_flags_e) {
self.rawValue = cFlags.rawValue
}
var cFlags: ghostty_binding_flags_e {
ghostty_binding_flags_e(rawValue)
}
}
}
// MARK: Ghostty.Input.KeyEvent
extension Ghostty.Input {
@@ -135,7 +165,7 @@ extension Ghostty.Input {
case GHOSTTY_ACTION_REPEAT: self.action = .repeat
default: self.action = .press
}
// Convert key from keycode
guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
self.key = key
@@ -146,18 +176,18 @@ extension Ghostty.Input {
} else {
self.text = nil
}
// Set composing state
self.composing = cValue.composing
// Convert modifiers
self.mods = Mods(cMods: cValue.mods)
self.consumedMods = Mods(cMods: cValue.consumed_mods)
// Set unshifted codepoint
self.unshiftedCodepoint = cValue.unshifted_codepoint
}
/// Executes a closure with a temporary C representation of this KeyEvent.
///
/// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
@@ -176,7 +206,7 @@ extension Ghostty.Input {
keyEvent.mods = mods.cMods
keyEvent.consumed_mods = consumedMods.cMods
keyEvent.unshifted_codepoint = unshiftedCodepoint
// Handle text with proper memory management
if let text = text {
return text.withCString { textPtr in
@@ -199,7 +229,7 @@ extension Ghostty.Input {
case release
case press
case `repeat`
var cAction: ghostty_input_action_e {
switch self {
case .release: GHOSTTY_ACTION_RELEASE
@@ -228,7 +258,7 @@ extension Ghostty.Input {
let action: MouseState
let button: MouseButton
let mods: Mods
init(
action: MouseState,
button: MouseButton,
@@ -238,7 +268,7 @@ extension Ghostty.Input {
self.button = button
self.mods = mods
}
/// Creates a MouseEvent from C enum values.
///
/// This initializer converts C-style mouse input enums to Swift types.
@@ -255,7 +285,7 @@ extension Ghostty.Input {
case GHOSTTY_MOUSE_PRESS: self.action = .press
default: return nil
}
// Convert button
switch button {
case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown
@@ -264,7 +294,7 @@ extension Ghostty.Input {
case GHOSTTY_MOUSE_MIDDLE: self.button = .middle
default: return nil
}
// Convert modifiers
self.mods = Mods(cMods: mods)
}
@@ -275,7 +305,7 @@ extension Ghostty.Input {
let x: Double
let y: Double
let mods: Mods
init(
x: Double,
y: Double,
@@ -312,7 +342,7 @@ extension Ghostty.Input {
enum MouseState: String, CaseIterable {
case release
case press
var cMouseState: ghostty_input_mouse_state_e {
switch self {
case .release: GHOSTTY_MOUSE_RELEASE
@@ -340,13 +370,48 @@ extension Ghostty.Input {
case left
case right
case middle
case four
case five
case six
case seven
case eight
case nine
case ten
case eleven
var cMouseButton: ghostty_input_mouse_button_e {
switch self {
case .unknown: GHOSTTY_MOUSE_UNKNOWN
case .left: GHOSTTY_MOUSE_LEFT
case .right: GHOSTTY_MOUSE_RIGHT
case .middle: GHOSTTY_MOUSE_MIDDLE
case .four: GHOSTTY_MOUSE_FOUR
case .five: GHOSTTY_MOUSE_FIVE
case .six: GHOSTTY_MOUSE_SIX
case .seven: GHOSTTY_MOUSE_SEVEN
case .eight: GHOSTTY_MOUSE_EIGHT
case .nine: GHOSTTY_MOUSE_NINE
case .ten: GHOSTTY_MOUSE_TEN
case .eleven: GHOSTTY_MOUSE_ELEVEN
}
}
/// Initialize from NSEvent.buttonNumber
/// NSEvent buttonNumber: 0=left, 1=right, 2=middle, 3=back (button 8), 4=forward (button 9), etc.
init(fromNSEventButtonNumber buttonNumber: Int) {
switch buttonNumber {
case 0: self = .left
case 1: self = .right
case 2: self = .middle
case 3: self = .eight // Back button
case 4: self = .nine // Forward button
case 5: self = .six
case 6: self = .seven
case 7: self = .four
case 8: self = .five
case 9: self = .ten
case 10: self = .eleven
default: self = .unknown
}
}
}
@@ -378,18 +443,18 @@ extension Ghostty.Input {
/// for scroll events, matching the Zig `ScrollMods` packed struct.
struct ScrollMods {
let rawValue: Int32
/// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)
var precision: Bool {
rawValue & 0b0000_0001 != 0
}
/// The momentum phase of the scroll event for inertial scrolling
var momentum: Momentum {
let momentumBits = (rawValue >> 1) & 0b0000_0111
return Momentum(rawValue: UInt8(momentumBits)) ?? .none
}
init(precision: Bool = false, momentum: Momentum = .none) {
var value: Int32 = 0
if precision {
@@ -398,11 +463,11 @@ extension Ghostty.Input {
value |= Int32(momentum.rawValue) << 1
self.rawValue = value
}
init(rawValue: Int32) {
self.rawValue = rawValue
}
var cScrollMods: ghostty_input_scroll_mods_t {
rawValue
}
@@ -421,7 +486,7 @@ extension Ghostty.Input {
case ended = 4
case cancelled = 5
case mayBegin = 6
var cMomentum: ghostty_input_mouse_momentum_e {
switch self {
case .none: GHOSTTY_MOUSE_MOMENTUM_NONE
@@ -438,7 +503,7 @@ extension Ghostty.Input {
extension Ghostty.Input.Momentum: AppEnum {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum")
static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [
.none: "None",
.began: "Began",
@@ -475,7 +540,7 @@ extension Ghostty.Input {
/// `ghostty_input_mods_e`
struct Mods: OptionSet {
let rawValue: UInt32
static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
@@ -486,23 +551,23 @@ extension Ghostty.Input {
static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
var cMods: ghostty_input_mods_e {
ghostty_input_mods_e(rawValue)
}
init(rawValue: UInt32) {
self.rawValue = rawValue
}
init(cMods: ghostty_input_mods_e) {
self.rawValue = cMods.rawValue
}
init(nsFlags: NSEvent.ModifierFlags) {
self.init(cMods: Ghostty.ghosttyMods(nsFlags))
}
var nsFlags: NSEvent.ModifierFlags {
Ghostty.eventModifierFlags(mods: cMods)
}
@@ -1116,43 +1181,43 @@ extension Ghostty.Input.Key: AppEnum {
return [
// Letters (A-Z)
.a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
// Numbers (0-9)
.digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
// Common Control Keys
.space, .enter, .tab, .backspace, .escape, .delete,
// Arrow Keys
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
// Navigation Keys
.home, .end, .pageUp, .pageDown, .insert,
// Function Keys (F1-F20)
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
.f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
// Modifier Keys
.shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
.metaLeft, .metaRight, .capsLock,
// Punctuation & Symbols
.minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
.semicolon, .quote, .comma, .period, .slash,
// Numpad
.numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
.numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
.numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
.numpadEnter, .numpadComma,
// Media Keys
.audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
// International Keys
.intlBackslash, .intlRo, .intlYen,
// Other
.contextMenu
]
@@ -1163,11 +1228,11 @@ extension Ghostty.Input.Key: AppEnum {
.a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
.k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
.u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
// Numbers (0-9)
.digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
.digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
// Common Control Keys
.space: "Space",
.enter: "Enter",
@@ -1175,26 +1240,26 @@ extension Ghostty.Input.Key: AppEnum {
.backspace: "Backspace",
.escape: "Escape",
.delete: "Delete",
// Arrow Keys
.arrowUp: "Up Arrow",
.arrowDown: "Down Arrow",
.arrowLeft: "Left Arrow",
.arrowRight: "Right Arrow",
// Navigation Keys
.home: "Home",
.end: "End",
.pageUp: "Page Up",
.pageDown: "Page Down",
.insert: "Insert",
// Function Keys (F1-F20)
.f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
.f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
.f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
.f18: "F18", .f19: "F19", .f20: "F20",
// Modifier Keys
.shiftLeft: "Left Shift",
.shiftRight: "Right Shift",
@@ -1205,7 +1270,7 @@ extension Ghostty.Input.Key: AppEnum {
.metaLeft: "Left Command",
.metaRight: "Right Command",
.capsLock: "Caps Lock",
// Punctuation & Symbols
.minus: "Minus (-)",
.equal: "Equal (=)",
@@ -1218,7 +1283,7 @@ extension Ghostty.Input.Key: AppEnum {
.comma: "Comma (,)",
.period: "Period (.)",
.slash: "Slash (/)",
// Numpad
.numLock: "Num Lock",
.numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
@@ -1232,17 +1297,17 @@ extension Ghostty.Input.Key: AppEnum {
.numpadEqual: "Numpad Equal",
.numpadEnter: "Numpad Enter",
.numpadComma: "Numpad Comma",
// Media Keys
.audioVolumeUp: "Volume Up",
.audioVolumeDown: "Volume Down",
.audioVolumeMute: "Volume Mute",
// International Keys
.intlBackslash: "International Backslash",
.intlRo: "International Ro",
.intlYen: "International Yen",
// Other
.contextMenu: "Context Menu"
]

View File

@@ -62,6 +62,26 @@ extension Ghostty {
}
}
/// Check if a key event matches a keybinding.
///
/// This checks whether the given key event would trigger a keybinding in the terminal.
/// If it matches, returns the binding flags indicating properties of the matched binding.
///
/// - Parameter event: The key event to check
/// - Returns: The binding flags if a binding matches, or nil if no binding matches
@MainActor
func keyIsBinding(_ event: ghostty_input_key_s) -> Input.BindingFlags? {
var flags = ghostty_binding_flags_e(0)
guard ghostty_surface_key_is_binding(surface, event, &flags) else { return nil }
return Input.BindingFlags(cFlags: flags)
}
/// See `keyIsBinding(_ event: ghostty_input_key_s)`.
@MainActor
func keyIsBinding(_ event: Input.KeyEvent) -> Input.BindingFlags? {
event.withCValue { keyIsBinding($0) }
}
/// Whether the terminal has captured mouse input.
///
/// When the mouse is captured, the terminal application is receiving mouse events
@@ -134,16 +154,5 @@ extension Ghostty {
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
}
}
/// Command options for this surface.
@MainActor
func commands() throws -> [Command] {
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
var count: Int = 0
ghostty_surface_commands(surface, &ptr, &count)
guard let ptr else { throw Error.apiFailed }
let buffer = UnsafeBufferPointer(start: ptr, count: count)
return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
}
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
extension Ghostty {
/// This is a delegate that should be applied to your global app delegate for GhosttyKit
/// to perform app-global operations.
protocol Delegate {
/// Look up a surface within the application by ID.
func ghosttySurface(id: UUID) -> SurfaceView?
}
}

View File

@@ -56,7 +56,7 @@ extension Ghostty {
case app
case zig_run
}
/// Returns the mechanism that launched the app. This is based on an env var so
/// its up to the env var being set in the correct circumstance.
static var launchSource: LaunchSource {
@@ -65,7 +65,7 @@ extension Ghostty {
// source. If its unset we assume we're in a CLI environment.
return .cli
}
// If the env var is set but its unknown then we default back to the app.
return LaunchSource(rawValue: envValue) ?? .app
}
@@ -76,17 +76,17 @@ extension Ghostty {
extension Ghostty {
class AllocatedString {
private let cString: ghostty_string_s
init(_ c: ghostty_string_s) {
self.cString = c
}
var string: String {
guard let ptr = cString.ptr else { return "" }
let data = Data(bytes: ptr, count: Int(cString.len))
return String(data: data, encoding: .utf8) ?? ""
}
deinit {
ghostty_string_free(cString)
}
@@ -330,6 +330,22 @@ extension Ghostty {
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
@@ -380,6 +396,9 @@ extension Notification.Name {
/// Close other tabs
static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs")
/// Close tabs to the right of the focused tab
static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight")
/// Close window
static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow")
@@ -388,6 +407,10 @@ extension Notification.Name {
/// Ring the bell
static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing")
/// Readonly mode changed
static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly")
static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly"
static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle")
/// Toggle maximize of current window
@@ -428,6 +451,9 @@ extension Ghostty.Notification {
/// New window. Has base surface config requested in userinfo.
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")
/// Present terminal. Bring the surface's window to focus without activating the app.
static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal")
/// Toggle fullscreen of current window
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue
@@ -465,6 +491,10 @@ extension Ghostty.Notification {
static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence")
static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence")
static let KeySequenceKey = didContinueKeySequence.rawValue + ".key"
/// Notifications related to key tables
static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable")
static let KeyTableKey = didChangeKeyTable.rawValue + ".action"
}
// Make the input enum hashable.

View File

@@ -0,0 +1,268 @@
import AppKit
import SwiftUI
extension Ghostty {
/// A preference key that propagates the ID of the SurfaceView currently being dragged,
/// or nil if no surface is being dragged.
struct DraggingSurfaceKey: PreferenceKey {
static var defaultValue: SurfaceView.ID? = nil
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
value = nextValue() ?? value
}
}
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
///
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
/// of terminal surfaces within split views. When the user drags this view, it initiates
/// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing
/// drop targets to identify which surface is being moved.
///
/// The view also publishes the dragging state via `DraggingSurfaceKey` preference,
/// enabling parent views to react to ongoing drag operations.
struct SurfaceDragSource: View {
/// The surface view that will be dragged.
let surfaceView: SurfaceView
/// Binding that reflects whether a drag session is currently active.
@Binding var isDragging: Bool
/// Binding that reflects whether the mouse is hovering over this view.
@Binding var isHovering: Bool
var body: some View {
SurfaceDragSourceViewRepresentable(
surfaceView: surfaceView,
isDragging: $isDragging,
isHovering: $isHovering)
.preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil)
}
}
/// An NSViewRepresentable that provides AppKit-based drag source functionality.
/// This gives us control over the drag lifecycle, particularly detecting drag start.
fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable {
let surfaceView: SurfaceView
@Binding var isDragging: Bool
@Binding var isHovering: Bool
func makeNSView(context: Context) -> SurfaceDragSourceView {
let view = SurfaceDragSourceView()
view.surfaceView = surfaceView
view.onDragStateChanged = { dragging in
isDragging = dragging
}
view.onHoverChanged = { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
return view
}
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
nsView.surfaceView = surfaceView
nsView.onDragStateChanged = { dragging in
isDragging = dragging
}
nsView.onHoverChanged = { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
}
}
/// The underlying NSView that handles drag operations.
///
/// This view manages mouse tracking and drag initiation for surface reordering.
/// It uses a local event loop to detect drag gestures and initiates an
/// `NSDraggingSession` when the user drags beyond the threshold distance.
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
/// Scale factor applied to the surface snapshot for the drag preview image.
private static let previewScale: CGFloat = 0.2
/// The surface view that will be dragged. Its UUID is encoded into the
/// pasteboard for drop targets to identify which surface is being moved.
var surfaceView: SurfaceView?
/// Callback invoked when the drag state changes. Called with `true` when
/// a drag session begins, and `false` when it ends (completed or cancelled).
var onDragStateChanged: ((Bool) -> Void)?
/// Callback invoked when the mouse enters or exits this view's bounds.
/// Used to update the hover state for visual feedback in the parent view.
var onHoverChanged: ((Bool) -> Void)?
/// Whether we are currently in a mouse tracking loop (between mouseDown
/// and either mouseUp or drag initiation). Used to determine cursor state.
private var isTracking: Bool = false
/// Local event monitor to detect escape key presses during drag.
private var escapeMonitor: Any?
/// Whether the current drag was cancelled by pressing escape.
private var dragCancelledByEscape: Bool = false
deinit {
if let escapeMonitor {
NSEvent.removeMonitor(escapeMonitor)
}
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
// Ensure this view gets the mouse event before window dragging handlers
return true
}
override func mouseDown(with event: NSEvent) {
// Consume the mouseDown event to prevent it from propagating to the
// window's drag handler. This fixes issue #10110 where grab handles
// would drag the window instead of initiating pane drags.
// Don't call super - the drag will be initiated in mouseDragged.
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
// Add our tracking area for mouse events
addTrackingArea(NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeInActiveApp],
owner: self,
userInfo: nil
))
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
}
override func mouseEntered(with event: NSEvent) {
onHoverChanged?(true)
}
override func mouseExited(with event: NSEvent) {
onHoverChanged?(false)
}
override func mouseDragged(with event: NSEvent) {
guard !isTracking, let surfaceView = surfaceView else { return }
// Create our dragging item from our transferable
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
// Create a scaled preview image from the surface snapshot
if let snapshot = surfaceView.asImage {
let imageSize = NSSize(
width: snapshot.size.width * Self.previewScale,
height: snapshot.size.height * Self.previewScale
)
let scaledImage = NSImage(size: imageSize)
scaledImage.lockFocus()
snapshot.draw(
in: NSRect(origin: .zero, size: imageSize),
from: NSRect(origin: .zero, size: snapshot.size),
operation: .copy,
fraction: 1.0
)
scaledImage.unlockFocus()
// Position the drag image so the mouse is at the center of the image.
// I personally like the top middle or top left corner best but
// this matches macOS native tab dragging behavior (at least, as of
// macOS 26.2 on Dec 29, 2025).
let mouseLocation = convert(event.locationInWindow, from: nil)
let origin = NSPoint(
x: mouseLocation.x - imageSize.width / 2,
y: mouseLocation.y - imageSize.height / 2
)
item.setDraggingFrame(
NSRect(origin: origin, size: imageSize),
contents: scaledImage
)
}
onDragStateChanged?(true)
let session = beginDraggingSession(with: [item], event: event, source: self)
// We need to disable this so that endedAt happens immediately for our
// drags outside of any targets.
session.animatesToStartingPositionsOnCancelOrFail = false
}
// MARK: NSDraggingSource
func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
return context == .withinApplication ? .move : []
}
func draggingSession(
_ session: NSDraggingSession,
willBeginAt screenPoint: NSPoint
) {
isTracking = true
// Reset our escape tracking
dragCancelledByEscape = false
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if event.keyCode == 53 { // Escape key
self?.dragCancelledByEscape = true
}
return event
}
}
func draggingSession(
_ session: NSDraggingSession,
movedTo screenPoint: NSPoint
) {
NSCursor.closedHand.set()
}
func draggingSession(
_ session: NSDraggingSession,
endedAt screenPoint: NSPoint,
operation: NSDragOperation
) {
if let escapeMonitor {
NSEvent.removeMonitor(escapeMonitor)
self.escapeMonitor = nil
}
if operation == [] && !dragCancelledByEscape {
let endsInWindow = NSApplication.shared.windows.contains { window in
window.isVisible && window.frame.contains(screenPoint)
}
if !endsInWindow {
NotificationCenter.default.post(
name: .ghosttySurfaceDragEndedNoTarget,
object: surfaceView,
userInfo: [Foundation.Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey: screenPoint]
)
}
}
isTracking = false
onDragStateChanged?(false)
}
}
}
extension Notification.Name {
/// Posted when a surface drag session ends with no operation (the drag was
/// released outside a valid drop target) and was not cancelled by the user
/// pressing escape. The notification's object is the SurfaceView that was dragged.
static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget")
/// Key for the screen point where the drag ended in the userInfo dictionary.
static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint"
}

View File

@@ -0,0 +1,41 @@
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
@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
)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

View File

@@ -34,10 +34,15 @@ class SurfaceScrollView: NSView {
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
scrollView.usesPredominantAxisScrolling = true
// Always use the overlay style. See mouseMoved for how we make
// it usable without a scroll wheel or gestures.
scrollView.scrollerStyle = .overlay
// hide default background to show blur effect properly
scrollView.drawsBackground = false
// don't let the content view clip it's subviews, to enable the
// don't let the content view clip its subviews, to enable the
// surface to draw the background behind non-overlay scrollers
// (we currently only use overlay scrollers, but might as well
// configure the views correctly in case we change our mind)
scrollView.contentView.clipsToBounds = false
// The document view is what the scrollview is actually going
@@ -107,24 +112,29 @@ class SurfaceScrollView: NSView {
observers.append(NotificationCenter.default.addObserver(
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleScrollerStyleChange()
})
// Listen for frame change events. See the docstring for
// handleFrameChange for why this is necessary.
observers.append(NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: nil,
// Since this observer is used to immediately override the event
// that produced the notification, we let it run synchronously on
// the posting thread.
queue: nil
) { [weak self] notification in
self?.handleFrameChange(notification)
) { [weak self] _ in
self?.handleScrollerStyleChange()
})
// Listen for frame change events on macOS 26.0. See the docstring for
// handleFrameChangeForNSScrollPocket for why this is necessary.
if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) {
observers.append(NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: nil,
// Since this observer is used to immediately override the event
// that produced the notification, we let it run synchronously on
// the posting thread.
queue: nil
) { [weak self] notification in
self?.handleFrameChangeForNSScrollPocket(notification)
})
}}
// Listen for derived config changes to update scrollbar settings live
surfaceView.$derivedConfig
.sink { [weak self] _ in
@@ -176,10 +186,10 @@ class SurfaceScrollView: NSView {
private func synchronizeAppearance() {
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
scrollView.hasVerticalScroller = scrollbarConfig != .never
scrollView.verticalScroller?.controlSize = .small
let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
// Make sure the scrollers appearance matches the surface's background color.
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
updateTrackingAreas()
}
/// Positions the surface view to fill the currently visible rectangle.
@@ -240,6 +250,7 @@ class SurfaceScrollView: NSView {
/// Handles scrollbar style changes
private func handleScrollerStyleChange() {
scrollView.scrollerStyle = .overlay
synchronizeCoreSurface()
}
@@ -319,7 +330,10 @@ class SurfaceScrollView: NSView {
/// and reset their frame to zero.
///
/// See also https://developer.apple.com/forums/thread/798392.
private func handleFrameChange(_ notification: Notification) {
///
/// This bug is only present in macOS 26.0.
@available(macOS, introduced: 26.0, obsoleted: 26.1)
private func handleFrameChangeForNSScrollPocket(_ notification: Notification) {
guard let window = window as? HiddenTitlebarTerminalWindow else { return }
guard !window.styleMask.contains(.fullScreen) else { return }
guard let view = notification.object as? NSView else { return }
@@ -350,4 +364,32 @@ class SurfaceScrollView: NSView {
}
return contentHeight
}
// MARK: Mouse events
override func mouseMoved(with: NSEvent) {
// When the OS preferred style is .legacy, the user should be able to
// click and drag the scroller without using scroll wheels or gestures,
// so we flash it when the mouse is moved over the scrollbar area.
guard NSScroller.preferredScrollerStyle == .legacy else { return }
scrollView.flashScrollers()
}
override func updateTrackingAreas() {
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
super.updateTrackingAreas()
// Our tracking area is the scroller frame
guard let scroller = scrollView.verticalScroller else { return }
addTrackingArea(NSTrackingArea(
rect: convert(scroller.bounds, from: scroller),
options: [
.mouseMoved,
.activeInKeyWindow,
],
owner: self,
userInfo: nil))
}
}

View File

@@ -0,0 +1,28 @@
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension Ghostty.SurfaceView {
#if canImport(AppKit)
/// A snapshot image of the current surface view.
var asImage: NSImage? {
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: bitmapRep)
let image = NSImage(size: bounds.size)
image.addRepresentation(bitmapRep)
return image
}
#elseif canImport(UIKit)
/// A snapshot image of the current surface view.
var asImage: UIImage? {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { _ in
drawHierarchy(in: bounds, afterScreenUpdates: true)
}
}
#endif
}

View File

@@ -0,0 +1,58 @@
#if canImport(AppKit)
import AppKit
#endif
import CoreTransferable
import UniformTypeIdentifiers
/// Conformance to `Transferable` enables drag-and-drop.
extension Ghostty.SurfaceView: Transferable {
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .ghosttySurfaceId) { surface in
withUnsafeBytes(of: surface.id.uuid) { Data($0) }
} importing: { data in
guard data.count == 16 else {
throw TransferError.invalidData
}
let uuid = data.withUnsafeBytes {
$0.load(as: UUID.self)
}
guard let imported = await Self.find(uuid: uuid) else {
throw TransferError.invalidData
}
return imported
}
}
enum TransferError: Error {
case invalidData
}
@MainActor
static func find(uuid: UUID) -> Self? {
#if canImport(AppKit)
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
return del.ghosttySurface(id: uuid) as? Self
#elseif canImport(UIKit)
// We should be able to use UIApplication here.
return nil
#else
return nil
#endif
}
}
extension UTType {
/// A format that encodes the bare UUID only for the surface. This can be used if you have
/// a way to look up a surface by ID.
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
}
#if canImport(AppKit)
extension NSPasteboard.PasteboardType {
/// Pasteboard type for dragging surface IDs.
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
}
#endif

View File

@@ -49,7 +49,7 @@ extension Ghostty {
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
@@ -116,31 +116,18 @@ extension Ghostty {
}
#if canImport(AppKit)
// If we are in the middle of a key sequence, then we show a visual element. We only
// support this on macOS currently although in theory we can support mobile with keyboards!
if !surfaceView.keySequence.isEmpty {
let padding: CGFloat = 5
VStack {
Spacer()
HStack {
Text(verbatim: "Pending Key Sequence:")
ForEach(0..<surfaceView.keySequence.count, id: \.description) { index in
let key = surfaceView.keySequence[index]
Text(verbatim: key.description)
.font(.system(.body, design: .monospaced))
.padding(3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(NSColor.selectedTextBackgroundColor))
)
}
}
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.frame(maxWidth: .infinity)
.background(.background)
// Readonly indicator badge
if surfaceView.readonly {
ReadonlyBadge {
surfaceView.toggleReadonly(nil)
}
}
// Show key state indicator for active key tables and/or pending key sequences
KeyStateIndicator(
keyTables: surfaceView.keyTables,
keySequence: surfaceView.keySequence
)
#endif
// If we have a URL from hovering a link, we show that.
@@ -203,7 +190,12 @@ extension Ghostty {
SurfaceSearchOverlay(
surfaceView: surfaceView,
searchState: searchState,
onClose: { surfaceView.searchState = nil }
onClose: {
#if canImport(AppKit)
Ghostty.moveFocus(to: surfaceView)
#endif
surfaceView.searchState = nil
}
)
}
@@ -212,6 +204,9 @@ extension Ghostty {
BellBorderOverlay(bell: surfaceView.bell)
}
// Show a highlight effect when this surface needs attention
HighlightOverlay(highlighted: surfaceView.highlighted)
// If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor)
@@ -234,7 +229,16 @@ extension Ghostty {
.opacity(overlayOpacity)
}
}
#if canImport(AppKit)
// Grab handle for dragging the window. We want this to appear at the very
// top Z-index os it isn't faded by the unfocused overlay.
//
// This is disabled except on macOS because it uses AppKit drag/drop APIs.
SurfaceGrabHandle(surfaceView: surfaceView)
#endif
}
}
}
@@ -432,7 +436,11 @@ extension Ghostty {
}
#if canImport(AppKit)
.onExitCommand {
Ghostty.moveFocus(to: surfaceView)
if searchState.needle.isEmpty {
onClose()
} else {
Ghostty.moveFocus(to: surfaceView)
}
}
#endif
.backport.onKeyPress(.return) { modifiers in
@@ -476,7 +484,9 @@ extension Ghostty {
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
guard notification.object as? SurfaceView === surfaceView else { return }
isSearchFieldFocused = true
DispatchQueue.main.async {
isSearchFieldFocused = true
}
}
.background(
GeometryReader { barGeo in
@@ -647,6 +657,9 @@ extension Ghostty {
/// Wait after the command
var waitAfterCommand: Bool = false
/// Context for surface creation
var context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_WINDOW
init() {}
init(from config: ghostty_surface_config_s) {
@@ -668,6 +681,7 @@ extension Ghostty {
}
}
}
self.context = config.context
}
/// Provides a C-compatible ghostty configuration within a closure. The configuration
@@ -701,6 +715,9 @@ extension Ghostty {
// Set wait after command
config.wait_after_command = waitAfterCommand
// Set context
config.context = context
// Use withCString to ensure strings remain valid for the duration of the closure
return try workingDirectory.withCString { cWorkingDir in
config.working_directory = cWorkingDir
@@ -741,6 +758,226 @@ extension Ghostty {
}
}
#if canImport(AppKit)
/// Floating indicator that shows active key tables and pending key sequences.
/// Displayed as a compact draggable pill that can be positioned at the top or bottom.
struct KeyStateIndicator: View {
let keyTables: [String]
let keySequence: [KeyboardShortcut]
@State private var isShowingPopover = false
@State private var position: Position = .bottom
@State private var dragOffset: CGSize = .zero
@State private var isDragging = false
private let padding: CGFloat = 8
enum Position {
case top, bottom
var alignment: Alignment {
switch self {
case .top: return .top
case .bottom: return .bottom
}
}
var popoverEdge: Edge {
switch self {
case .top: return .top
case .bottom: return .bottom
}
}
var transitionEdge: Edge {
popoverEdge
}
}
var body: some View {
Group {
if !keyTables.isEmpty || !keySequence.isEmpty {
content
.backport.pointerStyle(!keyTables.isEmpty ? .link : nil)
}
}
.transition(.move(edge: position.transitionEdge).combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count)
}
var content: some View {
indicatorContent
.offset(dragOffset)
.padding(padding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment)
.highPriorityGesture(
DragGesture(coordinateSpace: .local)
.onChanged { value in
isDragging = true
dragOffset = CGSize(width: 0, height: value.translation.height)
}
.onEnded { value in
isDragging = false
let dragThreshold: CGFloat = 50
withAnimation(.easeOut(duration: 0.2)) {
if position == .bottom && value.translation.height < -dragThreshold {
position = .top
} else if position == .top && value.translation.height > dragThreshold {
position = .bottom
}
dragOffset = .zero
}
}
)
}
@ViewBuilder
private var indicatorContent: some View {
HStack(alignment: .center, spacing: 8) {
// Key table indicator
if !keyTables.isEmpty {
HStack(spacing: 5) {
Image(systemName: "keyboard.badge.ellipsis")
.font(.system(size: 13))
.foregroundStyle(.secondary)
// Show table stack with arrows between them
ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in
if index > 0 {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.tertiary)
}
Text(verbatim: table)
.font(.system(size: 13, weight: .medium, design: .rounded))
}
}
}
// Separator when both are active
if !keyTables.isEmpty && !keySequence.isEmpty {
Divider()
.frame(height: 14)
}
// Key sequence indicator
if !keySequence.isEmpty {
HStack(alignment: .center, spacing: 4) {
ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in
KeyCap(key.description)
}
// Animated ellipsis to indicate waiting for next key
PendingIndicator(paused: isDragging)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background {
Capsule()
.fill(.regularMaterial)
.overlay {
Capsule()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
}
.contentShape(Capsule())
.backport.pointerStyle(.link)
.popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) {
VStack(alignment: .leading, spacing: 8) {
if !keyTables.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Key Table", systemImage: "keyboard.badge.ellipsis")
.font(.headline)
Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if !keyTables.isEmpty && !keySequence.isEmpty {
Divider()
}
if !keySequence.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Key Sequence", systemImage: "character.cursor.ibeam")
.font(.headline)
Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.padding()
.frame(maxWidth: 400)
.fixedSize(horizontal: false, vertical: true)
}
.onTapGesture {
isShowingPopover.toggle()
}
}
/// A small keycap-style view for displaying keyboard shortcuts
struct KeyCap: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(verbatim: text)
.font(.system(size: 12, weight: .medium, design: .rounded))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5)
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)
)
}
}
/// Animated dots to indicate waiting for the next key
struct PendingIndicator: View {
@State private var animationPhase: Double = 0
let paused: Bool
var body: some View {
TimelineView(.animation(paused: paused)) { context in
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(Color.secondary)
.frame(width: 4, height: 4)
.opacity(dotOpacity(for: index))
}
}
.onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in
animationPhase = newValue
}
}
}
private func dotOpacity(for index: Int) -> Double {
let phase = animationPhase
let offset = Double(index) / 3.0
let wave = sin((phase + offset) * .pi * 2)
return 0.3 + 0.7 * ((wave + 1) / 2)
}
}
}
#endif
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
struct BellBorderOverlay: View {
let bell: Bool
@@ -757,6 +994,152 @@ extension Ghostty {
}
}
/// Visual overlay that briefly highlights a surface to draw attention to it.
/// Uses a soft, soothing highlight with a pulsing border effect.
struct HighlightOverlay: View {
let highlighted: Bool
@State private var borderPulse: Bool = false
var body: some View {
ZStack {
Rectangle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color.accentColor.opacity(0.12),
Color.accentColor.opacity(0.03),
Color.clear
]),
center: .center,
startRadius: 0,
endRadius: 2000
)
)
Rectangle()
.strokeBorder(
LinearGradient(
gradient: Gradient(colors: [
Color.accentColor.opacity(0.8),
Color.accentColor.opacity(0.5),
Color.accentColor.opacity(0.8)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: borderPulse ? 4 : 2
)
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0)
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0)
}
.allowsHitTesting(false)
.opacity(highlighted ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.4), value: highlighted)
.onChange(of: highlighted) { newValue in
if newValue {
withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) {
borderPulse = true
}
} else {
withAnimation(.easeOut(duration: 0.4)) {
borderPulse = false
}
}
}
}
}
// MARK: Readonly Badge
/// A badge overlay that indicates a surface is in readonly mode.
/// Positioned in the top-right corner and styled to be noticeable but unobtrusive.
struct ReadonlyBadge: View {
let onDisable: () -> Void
@State private var showingPopover = false
private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8)
var body: some View {
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Image(systemName: "eye.fill")
.font(.system(size: 12))
Text("Read-only")
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(badgeBackground)
.foregroundStyle(badgeColor)
.onTapGesture {
showingPopover = true
}
.backport.pointerStyle(.link)
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover)
}
}
.padding(8)
Spacer()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Read-only terminal")
}
private var badgeBackground: some View {
RoundedRectangle(cornerRadius: 6)
.fill(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5)
)
}
}
struct ReadonlyPopoverView: View {
let onDisable: () -> Void
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "eye.fill")
.foregroundColor(.orange)
.font(.system(size: 13))
Text("Read-Only Mode")
.font(.system(size: 13, weight: .semibold))
}
Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Disable") {
onDisable()
isPresented = false
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
.frame(width: 280)
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't

View File

@@ -9,7 +9,7 @@ extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
typealias ID = UUID
/// Unique ID per surface
let id: UUID
@@ -44,14 +44,14 @@ extension Ghostty {
// The hovered URL string
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
// If we have a new progress report, start a timer to remove it after 15 seconds
if progressReport != nil {
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
@@ -65,6 +65,9 @@ extension Ghostty {
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil {
didSet {
@@ -98,7 +101,7 @@ extension Ghostty {
}
}
}
// Cancellable for search state needle changes
private var searchNeedleCancellable: AnyCancellable?
@@ -123,6 +126,12 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
@@ -210,7 +219,7 @@ extension Ghostty {
// A timer to fallback to ghost emoji if no title is set within the grace period
private var titleFallbackTimer: Timer?
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
@@ -318,6 +327,11 @@ extension Ghostty {
selector: #selector(ghosttyDidEndKeySequence),
name: Ghostty.Notification.didEndKeySequence,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidChangeKeyTable),
name: Ghostty.Notification.didChangeKeyTable,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
@@ -333,6 +347,11 @@ extension Ghostty {
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidChangeReadonly(_:)),
name: .ghosttyDidChangeReadonly,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@@ -369,26 +388,6 @@ extension Ghostty {
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
// Observe our appearance so we can report the correct value to libghostty.
// This is the best way I know of to get appearance change notifications.
self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in
guard let appearance = change.newValue else { return }
guard let surface = view.surface else { return }
let scheme: ghostty_color_scheme_e
switch (appearance.name) {
case .aqua, .vibrantLight:
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
case .darkAqua, .vibrantDark:
scheme = GHOSTTY_COLOR_SCHEME_DARK
default:
return
}
ghostty_surface_set_color_scheme(surface, scheme)
}
// The UTTypes that can be dragged onto this view.
registerForDraggedTypes(Array(Self.dropTypes))
}
@@ -419,7 +418,7 @@ extension Ghostty {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
// Cancel progress report timer
progressReportTimer?.invalidate()
}
@@ -556,16 +555,16 @@ extension Ghostty {
// Add buttons
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
// Make the text field the first responder so it gets focus
alert.window.initialFirstResponder = textField
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
guard let self else { return }
// Check if the user clicked "OK"
guard response == .alertFirstButtonReturn else { return }
// Get the input text
let newTitle = textField.stringValue
if newTitle.isEmpty {
@@ -689,6 +688,22 @@ extension Ghostty {
}
}
@objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) {
guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
switch action {
case .activate(let name):
self.keyTables.append(name)
case .deactivate:
_ = self.keyTables.popLast()
case .deactivateAll:
self.keyTables.removeAll()
}
}
}
@objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) {
// Get our managed configuration object out
guard let config = notification.userInfo?[
@@ -723,6 +738,11 @@ extension Ghostty {
bell = true
}
@objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) {
guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return }
readonly = value
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
@@ -840,16 +860,16 @@ extension Ghostty {
override func otherMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods)
}
override func otherMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods)
}
@@ -968,7 +988,7 @@ extension Ghostty {
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
let precision = event.hasPreciseScrollingDeltas
if precision {
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2;
@@ -1161,17 +1181,10 @@ extension Ghostty {
/// Special case handling for some control keys
override func performKeyEquivalent(with event: NSEvent) -> Bool {
switch (event.type) {
case .keyDown:
// Continue, we care about key down events
break
default:
// Any other key event we don't care about. I don't think its even
// possible to receive any other event type.
return false
}
// We only care about key down events. It might not even be possible
// to receive any other event type here.
guard event.type == .keyDown else { return false }
// Only process events if we're focused. Some key events like C-/ macOS
// appears to send to the first view in the hierarchy rather than the
// the first responder (I don't know why). This prevents us from handling it.
@@ -1181,18 +1194,35 @@ extension Ghostty {
if (!focused) {
return false
}
// If this event as-is would result in a key binding then we send it.
if let surface {
// Get information about if this is a binding.
let bindingFlags = surfaceModel.flatMap { surface in
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
let match = (event.characters ?? "").withCString { ptr in
return (event.characters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
return ghostty_surface_key_is_binding(surface, ghosttyEvent)
return surface.keyIsBinding(ghosttyEvent)
}
if match {
self.keyDown(with: event)
return true
}
// If this is a binding then we want to perform it.
if let bindingFlags {
// Attempt to trigger a menu item for this key binding. We only do this if:
// - We're not in a key sequence or table (those are separate bindings)
// - The binding is NOT `all` (menu uses FirstResponder chain)
// - The binding is NOT `performable` (menu will always consume)
// - The binding is `consumed` (unconsumed bindings should pass through
// to the terminal, so we must not intercept them for the menu)
if keySequence.isEmpty,
keyTables.isEmpty,
bindingFlags.isDisjoint(with: [.all, .performable]),
bindingFlags.contains(.consumed) {
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
}
}
self.keyDown(with: event)
return true
}
let equivalent: String
@@ -1330,7 +1360,7 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing
// For text, we only encode UTF8 if we don't have a single control
// character. Control characters are encoded by Ghostty itself.
// Without this, `ctrl+enter` does the wrong thing.
@@ -1436,9 +1466,13 @@ extension Ghostty {
item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise")
item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "scope")
item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "eye.fill")
item.state = readonly ? .on : .off
menu.addItem(.separator())
item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "pencil.line")
item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
return menu
}
@@ -1485,7 +1519,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func find(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "start_search"
@@ -1493,7 +1527,23 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func selectionForFind(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search_selection"
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func scrollToSelection(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "scroll_to_selection"
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findNext(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search:next"
@@ -1509,7 +1559,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findHide(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "end_search"
@@ -1518,6 +1568,22 @@ extension Ghostty {
}
}
@IBAction func toggleReadonly(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "toggle_readonly"
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
/// Triggers a brief highlight animation on this surface.
func highlight() {
highlighted = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.highlighted = false
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
@@ -1553,7 +1619,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func changeTitle(_ sender: Any) {
promptTitle()
}
@@ -1614,6 +1680,7 @@ extension Ghostty {
struct DerivedConfig {
let backgroundColor: Color
let backgroundOpacity: Double
let backgroundBlur: Ghostty.Config.BackgroundBlur
let macosWindowShadow: Bool
let windowTitleFontFamily: String?
let windowAppearance: NSAppearance?
@@ -1622,6 +1689,7 @@ extension Ghostty {
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
self.backgroundOpacity = 1
self.backgroundBlur = .disabled
self.macosWindowShadow = true
self.windowTitleFontFamily = nil
self.windowAppearance = nil
@@ -1631,6 +1699,7 @@ extension Ghostty {
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
self.backgroundOpacity = config.backgroundOpacity
self.backgroundBlur = config.backgroundBlur
self.macosWindowShadow = config.macosWindowShadow
self.windowTitleFontFamily = config.windowTitleFontFamily
self.windowAppearance = .init(ghosttyConfig: config)
@@ -1663,7 +1732,7 @@ extension Ghostty {
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
self.init(app, baseConfig: config, uuid: uuid)
// Restore the saved title after initialization
if let title = savedTitle {
self.title = title
@@ -1880,6 +1949,17 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
guard let surfaceModel else { return }
// Process MacOS native scroll events
switch selector {
case #selector(moveToBeginningOfDocument(_:)):
_ = surfaceModel.perform(action: "scroll_to_top")
case #selector(moveToEndOfDocument(_:)):
_ = surfaceModel.perform(action: "scroll_to_bottom")
default:
break
}
print("SEL: \(selector)")
}
@@ -1920,14 +2000,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
// but get requested an image (we can't handle that at the time of writing this),
// so we must bubble up.
// Types we can receive
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// Types that we can send. Currently the same as receivable but I'm separating
// this out so we can modify this in the future.
let sendable: [NSPasteboard.PasteboardType] = receivable
// The sendable types that require a selection (currently all)
let sendableRequiresSelection = sendable
@@ -1944,7 +2024,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
return self
}
@@ -1990,10 +2070,14 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
let pb = NSPasteboard.ghosttySelection
guard let str = pb.getOpinionatedStringContents() else { return false }
return !str.isEmpty
case #selector(findHide):
return searchState != nil
case #selector(toggleReadonly):
item.state = readonly ? .on : .off
return true
default:
return true
}
@@ -2091,7 +2175,7 @@ extension Ghostty.SurfaceView {
override func accessibilitySelectedTextRange() -> NSRange {
return selectedRange()
}
/// Returns the currently selected text as a string.
/// This allows assistive technologies to read the selected content.
override func accessibilitySelectedText() -> String? {
@@ -2105,21 +2189,21 @@ extension Ghostty.SurfaceView {
let str = String(cString: text.text)
return str.isEmpty ? nil : str
}
/// Returns the number of characters in the terminal content.
/// This helps assistive technologies understand the size of the content.
override func accessibilityNumberOfCharacters() -> Int {
let content = cachedScreenContents.get()
return content.count
}
/// Returns the visible character range for the terminal.
/// For terminals, we typically show all content as visible.
override func accessibilityVisibleCharacterRange() -> NSRange {
let content = cachedScreenContents.get()
return NSRange(location: 0, length: content.count)
}
/// Returns the line number for a given character index.
/// This helps assistive technologies navigate by line.
override func accessibilityLine(for index: Int) -> Int {
@@ -2127,7 +2211,7 @@ extension Ghostty.SurfaceView {
let substring = String(content.prefix(index))
return substring.components(separatedBy: .newlines).count - 1
}
/// Returns a substring for the given range.
/// This allows assistive technologies to read specific portions of the content.
override func accessibilityString(for range: NSRange) -> String? {
@@ -2135,7 +2219,7 @@ extension Ghostty.SurfaceView {
guard let swiftRange = Range(range, in: content) else { return nil }
return String(content[swiftRange])
}
/// Returns an attributed string for the given range.
///
/// Note: right now this only applies font information. One day it'd be nice to extend
@@ -2146,9 +2230,9 @@ extension Ghostty.SurfaceView {
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
guard let surface = self.surface else { return nil }
guard let plainString = accessibilityString(for: range) else { return nil }
var attributes: [NSAttributedString.Key: Any] = [:]
// Try to get the font from the surface
if let fontRaw = ghostty_surface_quicklook_font(surface) {
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
@@ -2158,6 +2242,7 @@ extension Ghostty.SurfaceView {
return NSAttributedString(string: plainString, attributes: attributes)
}
}
/// Caches a value for some period of time, evicting it automatically when that time expires.

View File

@@ -4,8 +4,10 @@ import GhosttyKit
extension Ghostty {
/// The UIView implementation for a terminal surface.
class SurfaceView: UIView, ObservableObject {
typealias ID = UUID
/// Unique ID per surface
let uuid: UUID
let id: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
@@ -44,6 +46,15 @@ extension Ghostty {
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? {
@@ -54,7 +65,7 @@ extension Ghostty {
private(set) var surface: ghostty_surface_t?
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.uuid = uuid ?? .init()
self.id = uuid ?? .init()
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer

View File

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

View File

@@ -1,19 +0,0 @@
import Cocoa
import SwiftUI
struct DraggableWindowView: NSViewRepresentable {
func makeNSView(context: Context) -> DraggableWindowNSView {
return DraggableWindowNSView()
}
func updateNSView(_ nsView: DraggableWindowNSView, context: Context) {
// No need to update anything here
}
}
class DraggableWindowNSView: NSView {
override func mouseDown(with event: NSEvent) {
guard let window = self.window else { return }
window.performDrag(with: event)
}
}

View File

@@ -0,0 +1,39 @@
import AppKit
extension NSColor {
/// Using a color list let's us get localized names.
private static let appleColorList: NSColorList? = NSColorList(named: "Apple")
convenience init?(named name: String) {
guard let colorList = Self.appleColorList,
let color = colorList.color(withKey: name.capitalized) else {
return nil
}
guard let components = color.usingColorSpace(.sRGB) else {
return nil
}
self.init(
red: components.redComponent,
green: components.greenComponent,
blue: components.blueComponent,
alpha: components.alphaComponent
)
}
static var colorNames: [String] {
appleColorList?.allKeys.map { $0.lowercased() } ?? []
}
/// Calculates the perceptual distance to another color in RGB space.
func distance(to other: NSColor) -> Double {
guard let a = self.usingColorSpace(.sRGB),
let b = other.usingColorSpace(.sRGB) else { return .infinity }
let dr = a.redComponent - b.redComponent
let dg = a.greenComponent - b.greenComponent
let db = a.blueComponent - b.blueComponent
// Weighted Euclidean distance (human eye is more sensitive to green)
return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db)
}
}

View File

@@ -0,0 +1,42 @@
import AppKit
extension NSMenu {
/// Inserts a menu item after an existing item with the specified action selector.
///
/// If an item with the same identifier already exists, it is removed first to avoid duplicates.
/// This is useful when menus are cached and reused across different targets.
///
/// - Parameters:
/// - item: The menu item to insert.
/// - action: The action selector to search for. The new item will be inserted after the first
/// item with this action.
/// - Returns: The index where the item was inserted, or `nil` if the action was not found
/// and the item was not inserted.
@discardableResult
func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? {
if let identifier = item.identifier,
let existing = items.first(where: { $0.identifier == identifier }) {
removeItem(existing)
}
guard let idx = items.firstIndex(where: { $0.action == action }) else {
return nil
}
let insertionIndex = idx + 1
insertItem(item, at: insertionIndex)
return UInt(insertionIndex)
}
/// Removes all menu items whose identifier is in the given set.
///
/// - Parameter identifiers: The set of identifiers to match for removal.
func removeItems(withIdentifiers identifiers: Set<NSUserInterfaceItemIdentifier>) {
for (index, item) in items.enumerated().reversed() {
guard let identifier = item.identifier else { continue }
if identifiers.contains(identifier) {
removeItem(at: index)
}
}
}
}

View File

@@ -10,25 +10,73 @@ extension NSWindow {
return CGWindowID(windowNumber)
}
/// True if this is the first window in the tab group.
var isFirstWindowInTabGroup: Bool {
guard let firstWindow = tabGroup?.windows.first else { return true }
return firstWindow === self
}
/// Adjusts the window origin if necessary to ensure the window remains visible on screen.
/// Adjusts the window frame if necessary to ensure the window remains visible on screen.
/// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen).
func constrainToScreen() {
guard let screen = screen ?? NSScreen.main else { return }
let visibleFrame = screen.visibleFrame
var windowFrame = frame
windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width)
windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height)
windowFrame.origin.x = max(visibleFrame.minX,
min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width))
windowFrame.origin.y = max(visibleFrame.minY,
min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height))
if windowFrame.origin != frame.origin {
setFrameOrigin(windowFrame.origin)
if windowFrame != frame {
setFrame(windowFrame, display: true)
}
}
}
// MARK: Native Tabbing
extension NSWindow {
/// True if this is the first window in the tab group.
var isFirstWindowInTabGroup: Bool {
guard let firstWindow = tabGroup?.windows.first else { return true }
return firstWindow === self
}
}
/// Native tabbing private API usage. :(
extension NSWindow {
var titlebarView: NSView? {
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
guard let themeFrameView = contentView?.rootView else { return nil }
guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil }
return themeFrameView.value(forKey: "titlebarView") as? NSView
}
/// Returns the [private] NSTabBar view, if it exists.
var tabBarView: NSView? {
titlebarView?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
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
}
}
return nil
}
}

View File

@@ -7,7 +7,7 @@ extension String {
return self.prefix(maxLength) + trailing
}
#if canImport(AppKit)
#if canImport(AppKit)
func temporaryFile(_ filename: String = "temp") -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(filename)
@@ -16,5 +16,14 @@ extension String {
try? string.write(to: url, atomically: true, encoding: .utf8)
return url
}
#endif
/// Returns the path with the home directory abbreviated as ~.
var abbreviatedPath: String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
if hasPrefix(home) {
return "~" + dropFirst(home.count)
}
return self
}
#endif
}

View File

@@ -0,0 +1,58 @@
import AppKit
import CoreTransferable
import UniformTypeIdentifiers
extension Transferable {
/// Converts this Transferable to an NSPasteboardItem with lazy data loading.
/// Data is only fetched when the pasteboard consumer requests it. This allows
/// bridging a Transferable to NSDraggingSource.
func pasteboardItem() -> NSPasteboardItem? {
let itemProvider = NSItemProvider()
itemProvider.register(self)
let types = itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) }
guard !types.isEmpty else { return nil }
let item = NSPasteboardItem()
let dataProvider = TransferableDataProvider(itemProvider: itemProvider)
let pasteboardTypes = types.map { NSPasteboard.PasteboardType($0.identifier) }
item.setDataProvider(dataProvider, forTypes: pasteboardTypes)
return item
}
}
private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProvider {
private let itemProvider: NSItemProvider
init(itemProvider: NSItemProvider) {
self.itemProvider = itemProvider
super.init()
}
func pasteboard(
_ pasteboard: NSPasteboard?,
item: NSPasteboardItem,
provideDataForType type: NSPasteboard.PasteboardType
) {
// NSPasteboardItemDataProvider requires synchronous data return, but
// NSItemProvider.loadDataRepresentation is async. We use a semaphore
// to block until the async load completes. This is safe because AppKit
// calls this method on a background thread during drag operations.
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in
result = data
semaphore.signal()
}
// Wait for the data to load
semaphore.wait()
// Set it. I honestly don't know what happens here if this fails.
if let data = result {
item.setData(data, forType: type)
}
}
}

View File

@@ -130,7 +130,7 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle {
class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
var fullscreenMode: FullscreenMode { .nonNative }
// Non-native fullscreen never supports tabs because tabs require
// the "titled" style and we don't have it for non-native fullscreen.
var supportsTabs: Bool { false }
@@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// Being untitled let's our content take up the full frame.
window.styleMask.remove(.titled)
// We dont' want the non-native fullscreen window to be resizable
// We don't want the non-native fullscreen window to be resizable
// from the edges.
window.styleMask.remove(.resizable)
@@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
if let window = window as? TerminalWindow, window.isTabBar(c) {
continue
}
if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
window.addTitlebarAccessoryViewController(c)
}
@@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// Removing "titled" also clears our toolbar
window.toolbar = savedState.toolbar
window.toolbarStyle = savedState.toolbarStyle
// If the window was previously in a tab group that isn't empty now,
// we re-add it. We have to do this because our process of doing non-native
// fullscreen removes the window from the tab group.
@@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.toolbar = window.toolbar
self.toolbarStyle = window.toolbarStyle
self.dock = window.screen?.hasDock ?? false
self.titlebarAccessoryViewControllers = if (window.hasTitleBar) {
// Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash.
window.titlebarAccessoryViewControllers

View File

@@ -0,0 +1,124 @@
import Testing
import AppKit
import CoreTransferable
import UniformTypeIdentifiers
@testable import Ghostty
struct TransferablePasteboardTests {
// MARK: - Test Helpers
/// A simple Transferable type for testing pasteboard conversion.
private struct DummyTransferable: Transferable, Equatable {
let payload: String
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .utf8PlainText) { value in
value.payload.data(using: .utf8)!
} importing: { data in
let string = String(data: data, encoding: .utf8)!
return DummyTransferable(payload: string)
}
}
}
/// A Transferable type that registers multiple content types.
private struct MultiTypeTransferable: Transferable {
let text: String
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(contentType: .utf8PlainText) { value in
value.text.data(using: .utf8)!
} importing: { data in
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
}
DataRepresentation(contentType: .plainText) { value in
value.text.data(using: .utf8)!
} importing: { data in
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
}
}
}
// MARK: - Basic Functionality
@Test func pasteboardItemIsCreated() {
let transferable = DummyTransferable(payload: "hello")
let item = transferable.pasteboardItem()
#expect(item != nil)
}
@Test func pasteboardItemContainsExpectedType() {
let transferable = DummyTransferable(payload: "hello")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
#expect(item.types.contains(expectedType))
}
@Test func pasteboardItemProvidesCorrectData() {
let transferable = DummyTransferable(payload: "test data")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
// Write to a pasteboard to trigger data provider
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.writeObjects([item])
// Read back the data
guard let data = pasteboard.data(forType: pasteboardType) else {
Issue.record("Expected data to be available on pasteboard")
return
}
let string = String(data: data, encoding: .utf8)
#expect(string == "test data")
}
// MARK: - Multiple Content Types
@Test func multipleTypesAreRegistered() {
let transferable = MultiTypeTransferable(text: "multi")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
#expect(item.types.contains(utf8Type))
#expect(item.types.contains(plainType))
}
@Test func multipleTypesProvideCorrectData() {
let transferable = MultiTypeTransferable(text: "shared content")
guard let item = transferable.pasteboardItem() else {
Issue.record("Expected pasteboard item to be created")
return
}
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.writeObjects([item])
// Both types should provide the same content
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
if let utf8Data = pasteboard.data(forType: utf8Type) {
#expect(String(data: utf8Data, encoding: .utf8) == "shared content")
}
if let plainData = pasteboard.data(forType: plainType) {
#expect(String(data: plainData, encoding: .utf8) == "shared content")
}
}
}

View File

@@ -0,0 +1,128 @@
import Testing
import Foundation
@testable import Ghostty
struct TerminalSplitDropZoneTests {
private let standardSize = CGSize(width: 100, height: 100)
// MARK: - Basic Edge Detection
@Test func topEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize)
#expect(zone == .top)
}
@Test func bottomEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize)
#expect(zone == .bottom)
}
@Test func leftEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize)
#expect(zone == .left)
}
@Test func rightEdge() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize)
#expect(zone == .right)
}
// MARK: - Corner Tie-Breaking
// When distances are equal, the check order determines the result:
// left -> right -> top -> bottom
@Test func topLeftCornerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize)
#expect(zone == .left)
}
@Test func topRightCornerSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize)
#expect(zone == .right)
}
@Test func bottomLeftCornerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize)
#expect(zone == .left)
}
@Test func bottomRightCornerSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize)
#expect(zone == .right)
}
// MARK: - Center Point (All Distances Equal)
@Test func centerSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize)
#expect(zone == .left)
}
// MARK: - Non-Square Aspect Ratio
@Test func rectangularViewTopEdge() {
let size = CGSize(width: 200, height: 100)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size)
#expect(zone == .top)
}
@Test func rectangularViewLeftEdge() {
let size = CGSize(width: 200, height: 100)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size)
#expect(zone == .left)
}
@Test func tallRectangleTopEdge() {
let size = CGSize(width: 100, height: 200)
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size)
#expect(zone == .top)
}
// MARK: - Out-of-Bounds Points
@Test func pointLeftOfViewSelectsLeft() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize)
#expect(zone == .left)
}
@Test func pointAboveViewSelectsTop() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize)
#expect(zone == .top)
}
@Test func pointRightOfViewSelectsRight() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize)
#expect(zone == .right)
}
@Test func pointBelowViewSelectsBottom() {
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize)
#expect(zone == .bottom)
}
// MARK: - Diagonal Regions (Triangular Zones)
@Test func upperLeftTriangleSelectsLeft() {
// Point in the upper-left triangle, closer to left than top
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize)
#expect(zone == .left)
}
@Test func upperRightTriangleSelectsRight() {
// Point in the upper-right triangle, closer to right than top
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize)
#expect(zone == .right)
}
@Test func lowerLeftTriangleSelectsLeft() {
// Point in the lower-left triangle, closer to left than bottom
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize)
#expect(zone == .left)
}
@Test func lowerRightTriangleSelectsRight() {
// Point in the lower-right triangle, closer to right than bottom
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize)
#expect(zone == .right)
}
}

View File

@@ -26,6 +26,7 @@
wasmtime,
wraptest,
zig,
zig_0_15,
zip,
llvmPackages_latest,
bzip2,
@@ -70,7 +71,6 @@
wayland-scanner,
wayland-protocols,
zon2nix,
system,
pkgs,
# needed by GTK for loading SVG icons while running from within the
# developer shell
@@ -100,7 +100,7 @@ in
scdoc
zig
zip
zon2nix.packages.${system}.zon2nix
zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix
# For web and wasm stuff
nodejs

View File

@@ -20,16 +20,6 @@
wayland-scanner,
pkgs,
}: let
# The Zig hook has no way to select the release type without actual
# overriding of the default flags.
#
# TODO: Once
# https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is
# ultimately acted on and has made its way to a nixpkgs implementation, this
# can probably be removed in favor of that.
zig_hook = zig_0_15.hook.overrideAttrs {
zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off";
};
gi_typelib_path = import ./build-support/gi-typelib-path.nix {
inherit pkgs lib stdenv;
};
@@ -73,7 +63,7 @@ in
ncurses
pandoc
pkg-config
zig_hook
zig_0_15
gobject-introspection
wrapGAppsHook4
blueprint-compiler
@@ -92,12 +82,16 @@ in
GI_TYPELIB_PATH = gi_typelib_path;
dontSetZigDefaultFlags = true;
zigBuildFlags = [
"--system"
"${finalAttrs.deps}"
"-Dversion-string=${finalAttrs.version}-${revision}-nix"
"-Dgtk-x11=${lib.boolToString enableX11}"
"-Dgtk-wayland=${lib.boolToString enableWayland}"
"-Dcpu=baseline"
"-Doptimize=${optimize}"
"-Dstrip=${lib.boolToString strip}"
];

283
nix/tests.nix Normal file
View File

@@ -0,0 +1,283 @@
{
self,
system,
nixpkgs,
home-manager,
...
}: let
nixos-version = nixpkgs.lib.trivial.release;
pkgs = import nixpkgs {
inherit system;
overlays = [
self.overlays.debug
];
};
pink_value = "#FF0087";
color_test = ''
import tempfile
import subprocess
def check_for_pink(final=False) -> bool:
with tempfile.NamedTemporaryFile() as tmpin:
machine.send_monitor_command("screendump {}".format(tmpin.name))
cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format(
tmpin.name
)
ret = subprocess.run(cmd, shell=True, capture_output=True)
if ret.returncode != 0:
raise Exception(
"image analysis failed with exit code {}".format(ret.returncode)
)
text = ret.stdout.decode("utf-8")
return "${pink_value}" in text
'';
mkNodeGnome = {
config,
pkgs,
settings,
sshPort ? null,
...
}: {
imports = [
./vm/wayland-gnome.nix
settings
];
virtualisation = {
forwardPorts = pkgs.lib.optionals (sshPort != null) [
{
from = "host";
host.port = sshPort;
guest.port = 22;
}
];
vmVariant = {
virtualisation.host.pkgs = pkgs;
};
};
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PermitEmptyPasswords = "yes";
};
};
security.pam.services.sshd.allowNullPassword = true;
users.groups.ghostty = {
gid = 1000;
};
users.users.ghostty = {
uid = 1000;
};
home-manager = {
users = {
ghostty = {
home = {
username = config.users.users.ghostty.name;
homeDirectory = config.users.users.ghostty.home;
stateVersion = nixos-version;
};
programs.ssh = {
enable = true;
extraOptionOverrides = {
StrictHostKeyChecking = "accept-new";
UserKnownHostsFile = "/dev/null";
};
};
};
};
};
system.stateVersion = nixos-version;
};
mkTestGnome = {
name,
settings,
testScript,
ocr ? false,
}:
pkgs.testers.runNixOSTest {
name = name;
enableOCR = ocr;
extraBaseModules = {
imports = [
home-manager.nixosModules.home-manager
];
};
nodes = {
machine = {
config,
pkgs,
...
}:
mkNodeGnome {
inherit config pkgs settings;
sshPort = 2222;
};
};
testScript = testScript;
};
in {
basic-version-check = pkgs.testers.runNixOSTest {
name = "basic-version-check";
nodes = {
machine = {pkgs, ...}: {
users.groups.ghostty = {};
users.users.ghostty = {
isNormalUser = true;
group = "ghostty";
extraGroups = ["wheel"];
hashedPassword = "";
packages = [
pkgs.ghostty
];
};
};
};
testScript = {...}: ''
machine.succeed("su - ghostty -c 'ghostty +version'")
'';
};
basic-window-check-gnome = mkTestGnome {
name = "basic-window-check-gnome";
settings = {
home-manager.users.ghostty = {
xdg.configFile = {
"ghostty/config".text = ''
background = ${pink_value}
'';
};
};
};
ocr = true;
testScript = {nodes, ...}: let
user = nodes.machine.users.users.ghostty;
bus_path = "/run/user/${toString user.uid}/bus";
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
gdbus = "${bus} gdbus";
ghostty = "${bus} ghostty";
su = command: "su - ${user.name} -c '${command}'";
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
in ''
${color_test}
with subtest("wait for x"):
start_all()
machine.wait_for_x()
machine.wait_for_file("${bus_path}")
with subtest("Ensuring no pink is present without the terminal."):
assert (
check_for_pink() == False
), "Pink was present on the screen before we even launched a terminal!"
machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
machine.succeed("${su "${ghostty} +new-window"}")
machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
machine.sleep(2)
with subtest("Have the terminal display a color."):
assert(
check_for_pink() == True
), "Pink was not found on the screen!"
machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}")
'';
};
ssh-integration-test = pkgs.testers.runNixOSTest {
name = "ssh-integration-test";
extraBaseModules = {
imports = [
home-manager.nixosModules.home-manager
];
};
nodes = {
server = {...}: {
users.groups.ghostty = {};
users.users.ghostty = {
isNormalUser = true;
group = "ghostty";
extraGroups = ["wheel"];
hashedPassword = "";
packages = [];
};
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PermitEmptyPasswords = "yes";
};
};
security.pam.services.sshd.allowNullPassword = true;
};
client = {
config,
pkgs,
...
}:
mkNodeGnome {
inherit config pkgs;
settings = {
home-manager.users.ghostty = {
xdg.configFile = {
"ghostty/config".text = let
in ''
shell-integration-features = ssh-terminfo
'';
};
};
};
sshPort = 2222;
};
};
testScript = {nodes, ...}: let
user = nodes.client.users.users.ghostty;
bus_path = "/run/user/${toString user.uid}/bus";
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
gdbus = "${bus} gdbus";
ghostty = "${bus} ghostty";
su = command: "su - ${user.name} -c '${command}'";
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
in ''
with subtest("Start server and wait for ssh to be ready."):
server.start()
server.wait_for_open_port(22)
with subtest("Start client and wait for ghostty window."):
client.start()
client.wait_for_x()
client.wait_for_file("${bus_path}")
client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
client.succeed("${su "${ghostty} +new-window"}")
client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied."):
client.sleep(2)
client.send_chars("ssh ghostty@server\n")
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
'';
};
}

View File

@@ -8,7 +8,7 @@
./common.nix
];
services.xserver = {
services = {
displayManager = {
gdm = {
enable = true;
@@ -22,6 +22,19 @@
};
};
systemd.user.services = {
"org.gnome.Shell@wayland" = {
serviceConfig = {
ExecStart = [
# Clear the list before overriding it.
""
# Eval API is now internal so Shell needs to run in unsafe mode.
"${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode"
];
};
};
};
environment.systemPackages = [
pkgs.gnomeExtensions.no-overview
];

View File

@@ -4,9 +4,6 @@
documentation.nixos.enable = false;
networking.hostName = "ghostty";
networking.domain = "mitchellh.com";
virtualisation.vmVariant = {
virtualisation.memorySize = 2048;
};
@@ -28,17 +25,11 @@
users.groups.ghostty = {};
users.users.ghostty = {
isNormalUser = true;
description = "Ghostty";
group = "ghostty";
extraGroups = ["wheel"];
isNormalUser = true;
initialPassword = "ghostty";
};
environment.etc = {
"xdg/autostart/com.mitchellh.ghostty.desktop" = {
source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop";
};
hashedPassword = "";
};
environment.systemPackages = [
@@ -61,6 +52,7 @@
services.displayManager = {
autoLogin = {
enable = true;
user = "ghostty";
};
};

View File

@@ -1,9 +0,0 @@
{...}: {
imports = [
./common-gnome.nix
];
services.displayManager = {
defaultSession = "gnome-xorg";
};
}

View File

@@ -1,125 +0,0 @@
const std = @import("std");
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const module = b.addModule("cimgui", .{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
const imgui_ = b.lazyDependency("imgui", .{});
const lib = b.addLibrary(.{
.name = "cimgui",
.root_module = b.createModule(.{
.target = target,
.optimize = optimize,
}),
.linkage = .static,
});
lib.linkLibC();
lib.linkLibCpp();
if (target.result.os.tag == .windows) {
lib.linkSystemLibrary("imm32");
}
// For dynamic linking, we prefer dynamic linking and to search by
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
if (b.systemIntegrationOption("freetype", .{})) {
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
const freetype = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
});
lib.linkLibrary(freetype.artifact("freetype"));
if (freetype.builder.lazyDependency(
"freetype",
.{},
)) |freetype_dep| {
module.addIncludePath(freetype_dep.path("include"));
}
}
if (imgui_) |imgui| lib.addIncludePath(imgui.path(""));
module.addIncludePath(b.path("vendor"));
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
try flags.appendSlice(b.allocator, &.{
"-DCIMGUI_FREETYPE=1",
"-DIMGUI_USE_WCHAR32=1",
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
});
if (target.result.os.tag == .windows) {
try flags.appendSlice(b.allocator, &.{
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
});
} else {
try flags.appendSlice(b.allocator, &.{
"-DIMGUI_IMPL_API=extern\t\"C\"",
});
}
if (imgui_) |imgui| {
lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_draw.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_demo.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_widgets.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("imgui_tables.cpp"), .flags = flags.items });
lib.addCSourceFile(.{ .file = imgui.path("misc/freetype/imgui_freetype.cpp"), .flags = flags.items });
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_opengl3.cpp"),
.flags = flags.items,
});
if (target.result.os.tag.isDarwin()) {
if (!target.query.isNative()) {
try @import("apple_sdk").addPaths(b, lib);
}
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_metal.mm"),
.flags = flags.items,
});
if (target.result.os.tag == .macos) {
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_osx.mm"),
.flags = flags.items,
});
}
}
}
lib.installHeadersDirectory(
b.path("vendor"),
"",
.{ .include_extensions = &.{".h"} },
);
b.installArtifact(lib);
const test_exe = b.addTest(.{
.name = "test",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
}),
});
test_exe.linkLibrary(lib);
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}

View File

@@ -1,19 +0,0 @@
.{
.name = .cimgui,
.version = "1.90.6", // -docking branch
.fingerprint = 0x49726f5f8acbc90d,
.paths = .{""},
.dependencies = .{
// This should be kept in sync with the submodule in the cimgui source
// code in ./vendor/ to be safe that they're compatible.
.imgui = .{
// ocornut/imgui
.url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
.hash = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
.lazy = true,
},
.apple_sdk = .{ .path = "../apple-sdk" },
.freetype = .{ .path = "../freetype" },
},
}

View File

@@ -1,4 +0,0 @@
pub const c = @cImport({
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
@cInclude("cimgui.h");
});

View File

@@ -1,20 +0,0 @@
pub const c = @import("c.zig").c;
// OpenGL
pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.c) bool;
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.c) void;
// Metal
pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.c) bool;
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.c) void;
pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.c) void;
// OSX
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
test {}

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