Merge branch 'ghostty-org:main' into main
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
sentry-cli dif upload --project ghostty --wait dsym.zip
|
||||
|
||||
build-macos:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -94,7 +94,7 @@ jobs:
|
|||
- name: Build Ghostty.app
|
||||
run: |
|
||||
cd macos
|
||||
sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
xcodebuild -target Ghostty -configuration Release
|
||||
|
||||
# We inject the "build number" as simply the number of commits since HEAD.
|
||||
|
|
@ -199,7 +199,7 @@ jobs:
|
|||
destination-dir: ./
|
||||
|
||||
build-macos-debug:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -246,7 +246,7 @@ jobs:
|
|||
- name: Build Ghostty.app
|
||||
run: |
|
||||
cd macos
|
||||
sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
xcodebuild -target Ghostty -configuration Release
|
||||
|
||||
# We inject the "build number" as simply the number of commits since HEAD.
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
|
||||
build-macos:
|
||||
needs: [setup]
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
|
||||
|
|
@ -139,7 +139,7 @@ jobs:
|
|||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.4.app
|
||||
|
||||
- name: Setup Sparkle
|
||||
env:
|
||||
|
|
@ -288,7 +288,7 @@ jobs:
|
|||
|
||||
appcast:
|
||||
needs: [setup, build-macos]
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
env:
|
||||
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
|
||||
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
|
||||
|
||||
- name: Update Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
|
|
@ -154,7 +154,7 @@ jobs:
|
|||
)
|
||||
}}
|
||||
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -173,7 +173,7 @@ jobs:
|
|||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
# Setup Sparkle
|
||||
- name: Setup Sparkle
|
||||
|
|
@ -299,7 +299,7 @@ jobs:
|
|||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
|
|
@ -369,7 +369,7 @@ jobs:
|
|||
)
|
||||
}}
|
||||
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -388,7 +388,7 @@ jobs:
|
|||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
# Setup Sparkle
|
||||
- name: Setup Sparkle
|
||||
|
|
@ -507,7 +507,7 @@ jobs:
|
|||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
|
|
@ -544,7 +544,7 @@ jobs:
|
|||
)
|
||||
}}
|
||||
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -563,7 +563,7 @@ jobs:
|
|||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
# Setup Sparkle
|
||||
- name: Setup Sparkle
|
||||
|
|
@ -682,7 +682,7 @@ jobs:
|
|||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ jobs:
|
|||
- build-nix
|
||||
- build-snap
|
||||
- build-macos
|
||||
- build-macos-tahoe
|
||||
- build-macos-matrix
|
||||
- build-windows
|
||||
- build-windows-cross
|
||||
|
|
@ -67,7 +68,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -98,7 +99,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -134,7 +135,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -163,7 +164,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -196,7 +197,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -240,7 +241,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -269,7 +270,7 @@ jobs:
|
|||
ghostty-source.tar.gz
|
||||
|
||||
build-macos:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -284,8 +285,8 @@ jobs:
|
|||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
- name: get the Zig deps
|
||||
id: deps
|
||||
|
|
@ -296,7 +297,47 @@ jobs:
|
|||
- name: Build GhosttyKit
|
||||
run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
|
||||
|
||||
# The native app is built with native XCode tooling. This also does
|
||||
# The native app is built with native Xcode tooling. This also does
|
||||
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
|
||||
# Nix breaks xcodebuild so this has to be run outside.
|
||||
- name: Build Ghostty.app
|
||||
run: cd macos && xcodebuild -target Ghostty
|
||||
|
||||
# Build the iOS target without code signing just to verify it works.
|
||||
- name: Build Ghostty iOS
|
||||
run: |
|
||||
cd macos
|
||||
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
||||
|
||||
build-macos-tahoe:
|
||||
runs-on: namespace-profile-ghostty-macos-tahoe
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
- name: get the Zig deps
|
||||
id: deps
|
||||
run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
|
||||
|
||||
# GhosttyKit is the framework that is built from Zig for our native
|
||||
# Mac app to access.
|
||||
- name: Build GhosttyKit
|
||||
run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
|
||||
|
||||
# The native app is built with native Xcode tooling. This also does
|
||||
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
|
||||
# Nix breaks xcodebuild so this has to be run outside.
|
||||
- name: Build Ghostty.app
|
||||
|
|
@ -309,7 +350,7 @@ jobs:
|
|||
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
||||
|
||||
build-macos-matrix:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -324,8 +365,8 @@ jobs:
|
|||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
- name: get the Zig deps
|
||||
id: deps
|
||||
|
|
@ -382,7 +423,7 @@ jobs:
|
|||
mkdir dist
|
||||
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -492,7 +533,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -523,7 +564,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -568,7 +609,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -607,7 +648,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -627,7 +668,7 @@ jobs:
|
|||
nix develop -c zig build -Dsentry=${{ matrix.sentry }}
|
||||
|
||||
test-macos:
|
||||
runs-on: namespace-profile-ghostty-macos
|
||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -642,8 +683,8 @@ jobs:
|
|||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.0.app
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
|
||||
- name: get the Zig deps
|
||||
id: deps
|
||||
|
|
@ -662,7 +703,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -689,7 +730,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -716,7 +757,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -743,7 +784,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -770,7 +811,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -797,7 +838,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4 # Check out repo so we can lint it
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -832,7 +873,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
@ -890,7 +931,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.7
|
||||
uses: namespacelabs/nscloud-cache-action@v1.2.8
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ zig-out/
|
|||
# macos is managed by XCode GUI
|
||||
macos/
|
||||
|
||||
# produced by Icon Composer on macOS
|
||||
images/Ghostty.icon/icon.json
|
||||
|
||||
# website dev run
|
||||
website/.next
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@
|
|||
# - @ghostty-org/localization/* - Anything related to localization
|
||||
# for a specific locale.
|
||||
#
|
||||
# - @ghosty-org/localization/manager - Manage all localization tasks
|
||||
# and tooling. They are not responsible for any specific locale but
|
||||
# are responsible for the overall localization process and tooling.
|
||||
#
|
||||
# - @ghostty-org/macos - The Ghostty macOS app and any macOS-specific
|
||||
# features, configurations, etc.
|
||||
#
|
||||
|
|
|
|||
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Mitchell Hashimoto
|
||||
Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
22
README.md
|
|
@ -224,6 +224,28 @@ macOS users don't require any additional dependencies.
|
|||
> source tarballs, see the
|
||||
> [website](http://ghostty.org/docs/install/build).
|
||||
|
||||
### Xcode Version and SDKs
|
||||
|
||||
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
|
||||
and the iOS SDK are all installed.
|
||||
|
||||
A common issue is that the incorrect version of Xcode is either
|
||||
installed or selected. Use the `xcode-select` command to
|
||||
ensure that the correct version of Xcode is selected:
|
||||
|
||||
```shell-session
|
||||
sudo xcode-select --switch /Applications/Xcode-beta.app
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Main branch development of Ghostty is preparing for the next major
|
||||
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
|
||||
> **Xcode 26 and the macOS 26 SDK**.
|
||||
>
|
||||
> You do not need to be running on macOS 26 to build Ghostty, you can
|
||||
> still use Xcode 26 beta on macOS 15 stable.
|
||||
|
||||
### Linting
|
||||
|
||||
#### Prettier
|
||||
|
|
|
|||
21
TODO.md
|
|
@ -1,21 +0,0 @@
|
|||
Performance:
|
||||
|
||||
- Loading fonts on startups should probably happen in multiple threads
|
||||
|
||||
Correctness:
|
||||
|
||||
- test wrap against wraptest: https://github.com/mattiase/wraptest
|
||||
- automate this in some way
|
||||
- Charsets: UTF-8 vs. ASCII mode
|
||||
- we only support UTF-8 input right now
|
||||
- need fallback glyphs if they're not supported
|
||||
- can effect a crash using `vttest` menu `3 10` since it tries to parse
|
||||
ASCII as UTF-8.
|
||||
|
||||
Mac:
|
||||
|
||||
- Preferences window
|
||||
|
||||
Major Features:
|
||||
|
||||
- Bell
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
.libxev = .{
|
||||
// mitchellh/libxev
|
||||
.url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
|
||||
.hash = "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz",
|
||||
.url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
|
||||
.hash = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3",
|
||||
.lazy = true,
|
||||
},
|
||||
.vaxis = .{
|
||||
|
|
@ -103,8 +103,8 @@
|
|||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
|
||||
.hash = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn",
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz",
|
||||
.hash = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS",
|
||||
.lazy = true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,20 +54,20 @@
|
|||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
||||
},
|
||||
"N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn": {
|
||||
"N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS": {
|
||||
"name": "iterm2_themes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
|
||||
"hash": "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4="
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz",
|
||||
"hash": "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4="
|
||||
},
|
||||
"N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": {
|
||||
"name": "libpng",
|
||||
"url": "https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz",
|
||||
"hash": "sha256-/syVtGzwXo4/yKQUdQ4LparQDYnp/fF16U/wQcrxoDo="
|
||||
},
|
||||
"libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz": {
|
||||
"libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3": {
|
||||
"name": "libxev",
|
||||
"url": "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
|
||||
"hash": "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o="
|
||||
"url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
|
||||
"hash": "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU="
|
||||
},
|
||||
"N-V-__8AAG3RoQEyRC2Vw7Qoro5SYBf62IHn3HjqtNVY6aWK": {
|
||||
"name": "libxml2",
|
||||
|
|
|
|||
|
|
@ -170,11 +170,11 @@ in
|
|||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn";
|
||||
name = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS";
|
||||
path = fetchZigArtifact {
|
||||
name = "iterm2_themes";
|
||||
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz";
|
||||
hash = "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4=";
|
||||
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz";
|
||||
hash = "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4=";
|
||||
};
|
||||
}
|
||||
{
|
||||
|
|
@ -186,11 +186,11 @@ in
|
|||
};
|
||||
}
|
||||
{
|
||||
name = "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz";
|
||||
name = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3";
|
||||
path = fetchZigArtifact {
|
||||
name = "libxev";
|
||||
url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz";
|
||||
hash = "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o=";
|
||||
url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz";
|
||||
hash = "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU=";
|
||||
};
|
||||
}
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.
|
|||
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
||||
https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz
|
||||
https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst
|
||||
https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz
|
||||
https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz
|
||||
https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz
|
||||
https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz
|
||||
https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz
|
||||
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz
|
||||
https://github.com/vancluever/z2d/archive/1bf4bc81819385f4b24596445c9a7cf3b3592b08.tar.gz
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
[Desktop Entry]
|
||||
Name=Ghostty
|
||||
Version=1.0
|
||||
Name=@NAME@
|
||||
Type=Application
|
||||
Comment=A terminal emulator
|
||||
Exec=ghostty
|
||||
TryExec=@GHOSTTY@
|
||||
Exec=@GHOSTTY@ --launched-from=desktop
|
||||
Icon=com.mitchellh.ghostty
|
||||
Categories=System;TerminalEmulator;
|
||||
Keywords=terminal;tty;pty;
|
||||
StartupNotify=true
|
||||
StartupWMClass=com.mitchellh.ghostty
|
||||
StartupWMClass=@APPID@
|
||||
Terminal=false
|
||||
Actions=new-window;
|
||||
X-GNOME-UsesNotifications=true
|
||||
|
|
@ -16,7 +18,8 @@ X-TerminalArgTitle=--title=
|
|||
X-TerminalArgAppId=--class=
|
||||
X-TerminalArgDir=--working-directory=
|
||||
X-TerminalArgHold=--wait-after-command
|
||||
DBusActivatable=true
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Exec=ghostty
|
||||
Exec=@GHOSTTY@ --launched-from=desktop
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.mitchellh.ghostty</id>
|
||||
<launchable type="desktop-id">com.mitchellh.ghostty.desktop</launchable>
|
||||
<name>Ghostty</name>
|
||||
<id>@APPID@</id>
|
||||
<launchable type="desktop-id">@APPID@.desktop</launchable>
|
||||
<name>@NAME@</name>
|
||||
<url type="homepage">https://ghostty.org</url>
|
||||
<url type="help">https://ghostty.org/docs</url>
|
||||
<url type="bugtracker">https://github.com/ghostty-org/ghostty/discussions</url>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[D-BUS Service]
|
||||
Name=@APPID@
|
||||
Exec=@GHOSTTY@ --launched-from=dbus
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
[D-BUS Service]
|
||||
Name=@APPID@
|
||||
SystemdService=@APPID@.service
|
||||
Exec=@GHOSTTY@ --launched-from=dbus
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
[Unit]
|
||||
Description=@NAME@
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=@APPID@
|
||||
ExecStart=@GHOSTTY@ --launched-from=systemd
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ghostty</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.mitchellh.ghostty</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Ghostty</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ghostty</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Ghostty.icns</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
54
flake.lock
|
|
@ -3,11 +3,11 @@
|
|||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -34,44 +34,24 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1741992157,
|
||||
"narHash": "sha256-nlIfTsTrMSksEJc1f7YexXiPVuzD1gOfeN1ggwZyUoc=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "da4b122f63095ca1199bd4d526f9e26426697689",
|
||||
"type": "github"
|
||||
"lastModified": 1748189127,
|
||||
"narHash": "sha256-zRDR+EbbeObu4V2X5QCd2Bk5eltfDlCr5yvhBwUT6pY=",
|
||||
"rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.802491.7c43f080a7f2/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "release-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1741865919,
|
||||
"narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
"type": "tarball",
|
||||
"url": "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"zig": "zig",
|
||||
"zon2nix": "zon2nix"
|
||||
}
|
||||
|
|
@ -98,15 +78,15 @@
|
|||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs-stable"
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1741825901,
|
||||
"narHash": "sha256-aeopo+aXg5I2IksOPFN79usw7AeimH1+tjfuMzJHFdk=",
|
||||
"lastModified": 1748261582,
|
||||
"narHash": "sha256-3i0IL3s18hdDlbsf0/E+5kyPRkZwGPbSFngq5eToiAA=",
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"rev": "0b14285e283f5a747f372fb2931835dd937c4383",
|
||||
"rev": "aafb1b093fb838f7a02613b719e85ec912914221",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -121,7 +101,7 @@
|
|||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs-unstable"
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
|
|
|
|||
42
flake.nix
|
|
@ -2,12 +2,10 @@
|
|||
description = "👻";
|
||||
|
||||
inputs = {
|
||||
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
|
||||
# We want to stay as up to date as possible but need to be careful that the
|
||||
# glibc versions used by our dependencies from Nix are compatible with the
|
||||
# system glibc that the user is building for.
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11";
|
||||
nixpkgs.url = "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
# Used for shell.nix
|
||||
|
|
@ -19,7 +17,7 @@
|
|||
zig = {
|
||||
url = "github:mitchellh/zig-overlay";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs-stable";
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.follows = "flake-utils";
|
||||
flake-compat.follows = "";
|
||||
};
|
||||
|
|
@ -28,7 +26,7 @@
|
|||
zon2nix = {
|
||||
url = "github:jcollie/zon2nix?ref=56c159be489cc6c0e73c3930bd908ddc6fe89613";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs-unstable";
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.follows = "flake-utils";
|
||||
};
|
||||
};
|
||||
|
|
@ -36,24 +34,19 @@
|
|||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs-unstable,
|
||||
nixpkgs-stable,
|
||||
nixpkgs,
|
||||
zig,
|
||||
zon2nix,
|
||||
...
|
||||
}:
|
||||
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
|
||||
builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
|
||||
builtins.map (
|
||||
system: let
|
||||
pkgs-stable = nixpkgs-stable.legacyPackages.${system};
|
||||
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
|
||||
zig = zig.packages.${system}."0.14.0";
|
||||
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
|
||||
uv = pkgs-unstable.uv;
|
||||
# remove once blueprint-compiler 0.16.0 is in the stable nixpkgs
|
||||
blueprint-compiler = pkgs-unstable.blueprint-compiler;
|
||||
devShell.${system} = pkgs.callPackage ./nix/devShell.nix {
|
||||
zig = zig.packages.${system}."0.14.1";
|
||||
wraptest = pkgs.callPackage ./nix/wraptest.nix {};
|
||||
zon2nix = zon2nix;
|
||||
};
|
||||
|
||||
|
|
@ -64,30 +57,29 @@
|
|||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
};
|
||||
in rec {
|
||||
deps = pkgs-unstable.callPackage ./build.zig.zon.nix {};
|
||||
ghostty-debug = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "Debug");
|
||||
ghostty-releasesafe = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
|
||||
ghostty-releasefast = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
|
||||
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-stable.alejandra;
|
||||
formatter.${system} = pkgs.alejandra;
|
||||
|
||||
apps.${system} = let
|
||||
runVM = (
|
||||
module: let
|
||||
vm = import ./nix/vm/create.nix {
|
||||
inherit system module;
|
||||
nixpkgs = nixpkgs-unstable;
|
||||
inherit system module nixpkgs;
|
||||
overlay = self.overlays.debug;
|
||||
};
|
||||
program = pkgs-unstable.writeShellScript "run-ghostty-vm" ''
|
||||
program = pkgs.writeShellScript "run-ghostty-vm" ''
|
||||
SHARED_DIR=$(pwd)
|
||||
export SHARED_DIR
|
||||
|
||||
${pkgs-unstable.lib.getExe vm.config.system.build.vm} "$@"
|
||||
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
|
||||
'';
|
||||
in {
|
||||
type = "app";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
app-id: com.mitchellh.ghostty.Devel
|
||||
app-id: com.mitchellh.ghostty-debug
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: "48"
|
||||
sdk: org.gnome.Sdk
|
||||
|
|
@ -6,11 +6,7 @@ sdk-extensions:
|
|||
- org.freedesktop.Sdk.Extension.ziglang
|
||||
default-branch: tip
|
||||
command: ghostty
|
||||
# Integrate the rename into zig build, maybe?
|
||||
rename-desktop-file: com.mitchellh.ghostty.desktop
|
||||
rename-appdata-file: com.mitchellh.ghostty.metainfo.xml
|
||||
rename-icon: com.mitchellh.ghostty
|
||||
desktop-file-name-suffix: " (Devel)"
|
||||
finish-args:
|
||||
# 3D rendering
|
||||
- --device=dri
|
||||
|
|
@ -67,9 +67,9 @@
|
|||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn",
|
||||
"sha256": "0ca595531644640f31fb79e33da4ee72bfeefcc9a179fd18c21c0e9ce5a7fcde"
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS",
|
||||
"sha256": "83da3608873f4df598a144bf97f1cfe4a644083eea36c75052516bb9a2a4573e"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
|
|
@ -79,9 +79,9 @@
|
|||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
|
||||
"dest": "vendor/p/libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz",
|
||||
"sha256": "a0a66a03d77bf631e7a7f1eca89590137dc57e7e447b91b85679507a942e638a"
|
||||
"url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
|
||||
"dest": "vendor/p/libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3",
|
||||
"sha256": "570141c83a29b6a88de5492894543b4db461c0c11145e0fd12bda98f14c26085"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -0,0 +1,170 @@
|
|||
{
|
||||
"color-space-for-untagged-svg-colors" : "display-p3",
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.87945,0.87945,0.87945,1.00000",
|
||||
"display-p3:0.40000,0.40000,0.40392,1.00000"
|
||||
]
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:1.00000,1.00000,1.00000,1.00000",
|
||||
"srgb:0.00000,0.00000,0.00000,1.00000"
|
||||
]
|
||||
},
|
||||
"hidden" : false,
|
||||
"image-name" : "gloss.png",
|
||||
"name" : "GlossTop",
|
||||
"opacity" : 0.25,
|
||||
"position" : {
|
||||
"scale" : 0.98,
|
||||
"translation-in-points" : [
|
||||
0.90625,
|
||||
-236.4609375
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"fill" : "automatic",
|
||||
"hidden" : false,
|
||||
"image-name" : "gloss.png",
|
||||
"name" : "gloss",
|
||||
"position" : {
|
||||
"scale" : 0.98,
|
||||
"translation-in-points" : [
|
||||
0.90625,
|
||||
-236.4609375
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group 4",
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"fill" : "automatic",
|
||||
"glass" : false,
|
||||
"hidden" : false,
|
||||
"image-name" : "Screen Effects.png",
|
||||
"name" : "Screen Effects"
|
||||
},
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"fill" : "automatic",
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "Screen Effects.png",
|
||||
"name" : "Screen Effects"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group 3",
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"fill" : "automatic",
|
||||
"hidden" : false,
|
||||
"image-name" : "Ghostty.png",
|
||||
"name" : "Ghostty",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
-185.015625,
|
||||
-143.8359375
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"fill" : {
|
||||
"solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
||||
},
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "Ghostty.png",
|
||||
"name" : "GhosttyBlur",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
-186.59375,
|
||||
-143.8359375
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"hidden" : false,
|
||||
"image-name" : "Screen.png",
|
||||
"name" : "Screen"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group 2",
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"blur-material" : null,
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Inner Bevel 6px.png",
|
||||
"name" : "Inner Bevel 6px"
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group 1",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.2
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 652 KiB |
|
|
@ -292,6 +292,11 @@ typedef enum {
|
|||
GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
|
||||
GHOSTTY_KEY_AUDIO_VOLUME_UP,
|
||||
GHOSTTY_KEY_WAKE_UP,
|
||||
|
||||
// "Legacy, Non-standard, and Special Keys" § 3.7
|
||||
GHOSTTY_KEY_COPY,
|
||||
GHOSTTY_KEY_CUT,
|
||||
GHOSTTY_KEY_PASTE,
|
||||
} ghostty_input_key_e;
|
||||
|
||||
typedef struct {
|
||||
|
|
@ -350,8 +355,41 @@ typedef struct {
|
|||
double tl_px_y;
|
||||
uint32_t offset_start;
|
||||
uint32_t offset_len;
|
||||
const char* text;
|
||||
uintptr_t text_len;
|
||||
} ghostty_text_s;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_POINT_ACTIVE,
|
||||
GHOSTTY_POINT_VIEWPORT,
|
||||
GHOSTTY_POINT_SCREEN,
|
||||
GHOSTTY_POINT_SURFACE,
|
||||
} ghostty_point_tag_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_POINT_COORD_EXACT,
|
||||
GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
} ghostty_point_coord_e;
|
||||
|
||||
typedef struct {
|
||||
ghostty_point_tag_e tag;
|
||||
ghostty_point_coord_e coord;
|
||||
uint32_t x;
|
||||
uint32_t y;
|
||||
} ghostty_point_s;
|
||||
|
||||
typedef struct {
|
||||
ghostty_point_s top_left;
|
||||
ghostty_point_s bottom_right;
|
||||
bool rectangle;
|
||||
} ghostty_selection_s;
|
||||
|
||||
typedef struct {
|
||||
const char* key;
|
||||
const char* value;
|
||||
} ghostty_env_var_s;
|
||||
|
||||
typedef struct {
|
||||
void* nsview;
|
||||
} ghostty_platform_macos_s;
|
||||
|
|
@ -373,6 +411,9 @@ typedef struct {
|
|||
float font_size;
|
||||
const char* working_directory;
|
||||
const char* command;
|
||||
ghostty_env_var_s* env_vars;
|
||||
size_t env_var_count;
|
||||
const char* initial_input;
|
||||
} ghostty_surface_config_s;
|
||||
|
||||
typedef struct {
|
||||
|
|
@ -648,6 +689,7 @@ typedef enum {
|
|||
GHOSTTY_ACTION_INITIAL_SIZE,
|
||||
GHOSTTY_ACTION_CELL_SIZE,
|
||||
GHOSTTY_ACTION_INSPECTOR,
|
||||
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
|
||||
GHOSTTY_ACTION_RENDER_INSPECTOR,
|
||||
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
|
||||
GHOSTTY_ACTION_SET_TITLE,
|
||||
|
|
@ -667,6 +709,8 @@ typedef enum {
|
|||
GHOSTTY_ACTION_CONFIG_CHANGE,
|
||||
GHOSTTY_ACTION_CLOSE_WINDOW,
|
||||
GHOSTTY_ACTION_RING_BELL,
|
||||
GHOSTTY_ACTION_UNDO,
|
||||
GHOSTTY_ACTION_REDO,
|
||||
GHOSTTY_ACTION_CHECK_FOR_UPDATES
|
||||
} ghostty_action_tag_e;
|
||||
|
||||
|
|
@ -771,13 +815,15 @@ void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
|
|||
|
||||
ghostty_surface_config_s ghostty_surface_config_new();
|
||||
|
||||
ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*);
|
||||
ghostty_surface_t ghostty_surface_new(ghostty_app_t,
|
||||
const ghostty_surface_config_s*);
|
||||
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);
|
||||
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);
|
||||
void ghostty_surface_refresh(ghostty_surface_t);
|
||||
void ghostty_surface_draw(ghostty_surface_t);
|
||||
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
|
||||
|
|
@ -823,16 +869,16 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
|
|||
void*,
|
||||
bool);
|
||||
bool ghostty_surface_has_selection(ghostty_surface_t);
|
||||
uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t);
|
||||
bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
|
||||
bool ghostty_surface_read_text(ghostty_surface_t,
|
||||
ghostty_selection_s,
|
||||
ghostty_text_s*);
|
||||
void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
|
||||
|
||||
#ifdef __APPLE__
|
||||
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
|
||||
void* ghostty_surface_quicklook_font(ghostty_surface_t);
|
||||
uintptr_t ghostty_surface_quicklook_word(ghostty_surface_t,
|
||||
char*,
|
||||
uintptr_t,
|
||||
ghostty_selection_s*);
|
||||
bool ghostty_surface_selection_info(ghostty_surface_t, ghostty_selection_s*);
|
||||
bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
|
||||
#endif
|
||||
|
||||
ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "macOS-AppIcon-1024px.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-16px-16pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-32px-16pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-32px-32pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-64px-32pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-128px-128pt@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-256px-128pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-256px-128pt@2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-512px-256pt@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-512px.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "macOS-AppIcon-1024px 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
|
@ -12,10 +12,18 @@
|
|||
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
|
||||
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
|
||||
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
|
||||
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
|
||||
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
|
||||
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; };
|
||||
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
|
||||
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; };
|
||||
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; };
|
||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
||||
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; };
|
||||
A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; };
|
||||
A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; };
|
||||
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; };
|
||||
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; };
|
||||
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; };
|
||||
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; };
|
||||
|
|
@ -50,7 +58,16 @@
|
|||
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
|
||||
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
|
||||
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
|
||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
|
||||
A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
|
||||
A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
|
||||
A553F4132E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
|
||||
A553F4142E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
|
||||
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; };
|
||||
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; };
|
||||
A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; };
|
||||
A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; };
|
||||
A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; };
|
||||
A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; };
|
||||
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
|
||||
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; };
|
||||
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
|
||||
|
|
@ -59,6 +76,12 @@
|
|||
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
|
||||
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
|
||||
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
|
||||
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
|
||||
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
|
||||
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
|
||||
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; };
|
||||
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; };
|
||||
A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; };
|
||||
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
|
||||
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
|
||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
||||
|
|
@ -66,9 +89,6 @@
|
|||
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; };
|
||||
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; };
|
||||
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; };
|
||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; };
|
||||
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; };
|
||||
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; };
|
||||
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
|
||||
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
|
||||
A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; };
|
||||
|
|
@ -78,9 +98,10 @@
|
|||
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
|
||||
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; };
|
||||
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; };
|
||||
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
|
||||
A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; };
|
||||
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; };
|
||||
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
|
||||
A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; };
|
||||
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
|
||||
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; };
|
||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
|
||||
|
|
@ -106,9 +127,20 @@
|
|||
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
|
||||
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
|
||||
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
|
||||
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; };
|
||||
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; };
|
||||
A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; };
|
||||
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; };
|
||||
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; };
|
||||
A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; };
|
||||
A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; };
|
||||
A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; };
|
||||
A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; };
|
||||
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
|
||||
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
|
||||
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
|
||||
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
|
||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
|
||||
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; };
|
||||
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
||||
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
||||
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
|
||||
|
|
@ -125,8 +157,16 @@
|
|||
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
|
||||
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
|
||||
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
|
||||
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
||||
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
|
||||
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
|
||||
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
|
||||
A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = "<group>"; };
|
||||
A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = "<group>"; };
|
||||
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
|
||||
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
|
||||
A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; };
|
||||
A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = "<group>"; };
|
||||
A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -155,7 +195,13 @@
|
|||
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
|
||||
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
|
||||
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
|
||||
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
|
||||
A553F4122E06EB1600257779 /* Ghostty.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = Ghostty.icon; path = ../images/Ghostty.icon; sourceTree = SOURCE_ROOT; };
|
||||
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; };
|
||||
A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = "<group>"; };
|
||||
A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
|
||||
A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = "<group>"; };
|
||||
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
|
||||
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = "<group>"; };
|
||||
A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; };
|
||||
|
|
@ -164,6 +210,12 @@
|
|||
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
|
||||
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
|
||||
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
|
||||
A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
|
||||
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
|
||||
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
|
||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; };
|
||||
A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = "<group>"; };
|
||||
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -171,9 +223,6 @@
|
|||
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
|
||||
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
||||
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = "<group>"; };
|
||||
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = "<group>"; };
|
||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
|
||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -182,11 +231,12 @@
|
|||
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
|
||||
A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = "<group>"; };
|
||||
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; };
|
||||
A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = "<group>"; };
|
||||
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = "<group>"; };
|
||||
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
|
||||
A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
|
||||
A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
|
||||
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -213,9 +263,20 @@
|
|||
A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ClipboardConfirmation.xib; sourceTree = "<group>"; };
|
||||
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
|
||||
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
|
||||
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; };
|
||||
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = "<group>"; };
|
||||
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = "<group>"; };
|
||||
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = "<group>"; };
|
||||
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = "<group>"; };
|
||||
A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = "<group>"; };
|
||||
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = "<group>"; };
|
||||
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = "<group>"; };
|
||||
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = "<group>"; };
|
||||
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
|
||||
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
|
||||
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
|
||||
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
|
||||
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
|
||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
|
||||
C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = "<group>"; };
|
||||
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = "<group>"; };
|
||||
|
|
@ -273,8 +334,10 @@
|
|||
A56D58872ACDE6BE00508D2C /* Services */,
|
||||
A59630982AEE1C4400D64628 /* Terminal */,
|
||||
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
|
||||
A5E4082C2E0237270035FEAC /* App Intents */,
|
||||
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
||||
A57D79252C9C8782001D522E /* Secure Input */,
|
||||
A58636622DEF955100E04A10 /* Splits */,
|
||||
A53A29742DB2E04900B6E02C /* Command Palette */,
|
||||
A534263E2A7DCC5800EBB7A2 /* Settings */,
|
||||
A51BFC1C2B2FB5AB00E92F16 /* About */,
|
||||
|
|
@ -287,34 +350,25 @@
|
|||
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A58636692DF0A98100E04A10 /* Extensions */,
|
||||
A5874D9B2DAD781100E83852 /* Private */,
|
||||
A5A6F7292CC41B8700B232A5 /* AppInfo.swift */,
|
||||
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
|
||||
A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
|
||||
A5CEAFFE29C2410700646FDA /* Backport.swift */,
|
||||
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
|
||||
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
|
||||
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
|
||||
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
|
||||
A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */,
|
||||
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
|
||||
A59630962AEE163600D64628 /* HostingWindow.swift */,
|
||||
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
|
||||
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
|
||||
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
|
||||
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
|
||||
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
|
||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
|
||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
|
||||
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
|
||||
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
|
||||
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
|
||||
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
|
||||
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
|
||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
|
||||
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
|
||||
A51194162E05D95E007258CC /* PermissionRequest.swift */,
|
||||
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
|
||||
A5CA378D2D31D6C100931030 /* Weak.swift */,
|
||||
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
|
||||
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
|
||||
A5CEAFDA29B8005900646FDA /* SplitView */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -388,6 +442,23 @@
|
|||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5593FDD2DF8D56000B47B10 /* Window Styles */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A59630992AEE1C6400D64628 /* Terminal.xib */,
|
||||
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */,
|
||||
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */,
|
||||
A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */,
|
||||
A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */,
|
||||
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
|
||||
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
|
||||
A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */,
|
||||
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */,
|
||||
A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */,
|
||||
);
|
||||
path = "Window Styles";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A55B7BB429B6F4410055DE60 /* Ghostty */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -397,14 +468,14 @@
|
|||
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
|
||||
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
|
||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
|
||||
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */,
|
||||
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
|
||||
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
|
||||
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */,
|
||||
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
|
||||
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
|
||||
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
|
||||
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
|
||||
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
|
||||
A55685DF29A03A9F004303CE /* AppError.swift */,
|
||||
A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */,
|
||||
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
|
||||
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
|
||||
);
|
||||
|
|
@ -428,6 +499,42 @@
|
|||
path = "Secure Input";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A58636622DEF955100E04A10 /* Splits */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A586365E2DEE6C2100E04A10 /* SplitTree.swift */,
|
||||
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */,
|
||||
A5CEAFDB29B8009000646FDA /* SplitView.swift */,
|
||||
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
|
||||
);
|
||||
path = Splits;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A58636692DF0A98100E04A10 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
|
||||
A50297342DFA0F3300B4E924 /* Double+Extension.swift */,
|
||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
|
||||
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
|
||||
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
|
||||
A51194122E05D003007258CC /* Optional+Extension.swift */,
|
||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
|
||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
|
||||
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
|
||||
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
|
||||
A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */,
|
||||
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
|
||||
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
|
||||
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
|
||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
|
||||
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
|
||||
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5874D9B2DAD781100E83852 /* Private */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -440,13 +547,10 @@
|
|||
A59630982AEE1C4400D64628 /* Terminal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A59630992AEE1C6400D64628 /* Terminal.xib */,
|
||||
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */,
|
||||
A5593FDD2DF8D56000B47B10 /* Window Styles */,
|
||||
A596309B2AEE1C9E00D64628 /* TerminalController.swift */,
|
||||
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */,
|
||||
A596309D2AEE1D6C00D64628 /* TerminalView.swift */,
|
||||
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */,
|
||||
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */,
|
||||
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
|
||||
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */,
|
||||
);
|
||||
|
|
@ -475,6 +579,7 @@
|
|||
children = (
|
||||
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */,
|
||||
A5B30538299BEAAB0047F10C /* Assets.xcassets */,
|
||||
A553F4122E06EB1600257779 /* Ghostty.icon */,
|
||||
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */,
|
||||
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */,
|
||||
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
|
||||
|
|
@ -515,15 +620,6 @@
|
|||
path = "Global Keybinds";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5CEAFDA29B8005900646FDA /* SplitView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5CEAFDB29B8009000646FDA /* SplitView.swift */,
|
||||
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
|
||||
);
|
||||
path = SplitView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5D495A3299BECBA00DD1313 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -543,6 +639,32 @@
|
|||
path = ClipboardConfirmation;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5E4082C2E0237270035FEAC /* App Intents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5E408412E0453370035FEAC /* Entities */,
|
||||
A511940E2E050590007258CC /* CloseTerminalIntent.swift */,
|
||||
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
|
||||
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
|
||||
A51194102E05A480007258CC /* QuickTerminalIntent.swift */,
|
||||
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */,
|
||||
A5E408462E0485270035FEAC /* InputIntent.swift */,
|
||||
A5E408442E0483F80035FEAC /* KeybindIntent.swift */,
|
||||
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
|
||||
A51194182E05DFBB007258CC /* IntentPermission.swift */,
|
||||
);
|
||||
path = "App Intents";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A5E408412E0453370035FEAC /* Entities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */,
|
||||
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */,
|
||||
);
|
||||
path = Entities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -630,9 +752,12 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */,
|
||||
A553F4142E06EB1600257779 /* Ghostty.icon in Resources */,
|
||||
A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */,
|
||||
29C15B1D2CDC3B2900520DD4 /* bat in Resources */,
|
||||
A586167C2B7703CC009BDB1D /* fish in Resources */,
|
||||
55154BE02B33911F001622DC /* ghostty in Resources */,
|
||||
A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */,
|
||||
A546F1142D7B68D7003B11A0 /* locale in Resources */,
|
||||
A5985CE62C33060F00C57AD3 /* man in Resources */,
|
||||
9351BE8E3D22937F003B3499 /* nvim in Resources */,
|
||||
|
|
@ -641,10 +766,12 @@
|
|||
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */,
|
||||
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */,
|
||||
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */,
|
||||
A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */,
|
||||
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */,
|
||||
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */,
|
||||
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */,
|
||||
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
|
||||
A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */,
|
||||
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -654,6 +781,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */,
|
||||
A553F4132E06EB1600257779 /* Ghostty.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -665,77 +793,101 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
|
||||
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
|
||||
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */,
|
||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
|
||||
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
|
||||
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
|
||||
A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */,
|
||||
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
|
||||
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
|
||||
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
|
||||
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
|
||||
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */,
|
||||
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
|
||||
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
|
||||
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
|
||||
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
|
||||
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */,
|
||||
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
|
||||
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
|
||||
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */,
|
||||
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
|
||||
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
|
||||
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
|
||||
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
||||
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
||||
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */,
|
||||
A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */,
|
||||
A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
|
||||
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
|
||||
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
|
||||
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
|
||||
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
||||
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
||||
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
|
||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
||||
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
|
||||
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
||||
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,
|
||||
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
|
||||
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
||||
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
||||
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
||||
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */,
|
||||
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */,
|
||||
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
|
||||
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
||||
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
||||
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
|
||||
A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */,
|
||||
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
|
||||
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
|
||||
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */,
|
||||
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
|
||||
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
|
||||
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
|
||||
A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */,
|
||||
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
|
||||
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
|
||||
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */,
|
||||
A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */,
|
||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
|
||||
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
|
||||
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
||||
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
|
||||
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
|
||||
A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */,
|
||||
A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */,
|
||||
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */,
|
||||
A5FEB3002ABB69450068369E /* main.swift in Sources */,
|
||||
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
|
||||
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */,
|
||||
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
|
||||
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
|
||||
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
|
||||
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
|
||||
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
|
||||
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
||||
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
|
||||
A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */,
|
||||
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
|
||||
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
|
||||
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
|
||||
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
|
||||
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
|
||||
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
|
||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
|
||||
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
|
||||
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
|
||||
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
|
||||
A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */,
|
||||
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */,
|
||||
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
|
||||
A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */,
|
||||
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */,
|
||||
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
|
||||
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
|
||||
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
|
||||
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */,
|
||||
A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
|
||||
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
|
||||
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
|
||||
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
|
||||
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */,
|
||||
|
|
@ -748,6 +900,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
|
||||
A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */,
|
||||
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
|
||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
|
||||
|
|
@ -757,6 +910,7 @@
|
|||
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
|
||||
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,
|
||||
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
||||
A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */,
|
||||
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -822,7 +976,7 @@
|
|||
3B39CAA32B33946300DABEB8 /* ReleaseLocal */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
|
@ -992,7 +1146,7 @@
|
|||
A5B30541299BEAAB0047F10C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
|
@ -1046,7 +1200,7 @@
|
|||
A5B30542299BEAAB0047F10C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
|
@ -1099,7 +1253,7 @@
|
|||
A5D449A82B53AE7B000F5B83 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
@ -1138,7 +1292,7 @@
|
|||
A5D449A92B53AE7B000F5B83 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
@ -1177,7 +1331,7 @@
|
|||
A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class AppDelegate: NSObject,
|
|||
)
|
||||
|
||||
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config
|
||||
@IBOutlet private var menuAbout: NSMenuItem?
|
||||
@IBOutlet private var menuServices: NSMenu?
|
||||
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
|
||||
@IBOutlet private var menuOpenConfig: NSMenuItem?
|
||||
|
|
@ -36,6 +37,8 @@ class AppDelegate: NSObject,
|
|||
@IBOutlet private var menuCloseWindow: NSMenuItem?
|
||||
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
|
||||
|
||||
@IBOutlet private var menuUndo: NSMenuItem?
|
||||
@IBOutlet private var menuRedo: NSMenuItem?
|
||||
@IBOutlet private var menuCopy: NSMenuItem?
|
||||
@IBOutlet private var menuPaste: NSMenuItem?
|
||||
@IBOutlet private var menuPasteSelection: NSMenuItem?
|
||||
|
|
@ -85,11 +88,14 @@ class AppDelegate: NSObject,
|
|||
/// The ghostty global state. Only one per process.
|
||||
let ghostty: Ghostty.App = Ghostty.App()
|
||||
|
||||
/// Manages our terminal windows.
|
||||
let terminalManager: TerminalManager
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = ExpiringUndoManager()
|
||||
|
||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||
private var quickController: QuickTerminalController? = nil
|
||||
private(set) lazy var quickController = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition
|
||||
)
|
||||
|
||||
/// Manages updates
|
||||
let updaterController: SPUStandardUpdaterController
|
||||
|
|
@ -114,7 +120,6 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
override init() {
|
||||
terminalManager = TerminalManager(ghostty)
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
// Important: we must not start the updater here because we need to read our configuration
|
||||
// first to determine whether we're automatically checking, downloading, etc. The updater
|
||||
|
|
@ -197,6 +202,16 @@ class AppDelegate: NSObject,
|
|||
name: .ghosttyBellDidRing,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyNewWindow(_:)),
|
||||
name: Ghostty.Notification.ghosttyNewWindow,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyNewTab(_:)),
|
||||
name: Ghostty.Notification.ghosttyNewTab,
|
||||
object: nil)
|
||||
|
||||
// Configure user notifications
|
||||
let actions = [
|
||||
|
|
@ -231,6 +246,9 @@ class AppDelegate: NSObject,
|
|||
|
||||
ghostty_app_set_color_scheme(app, scheme)
|
||||
}
|
||||
|
||||
// Setup our menu
|
||||
setupMenuImages()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ notification: Notification) {
|
||||
|
|
@ -248,8 +266,10 @@ class AppDelegate: NSObject,
|
|||
// is possible to have other windows in a few scenarios:
|
||||
// - if we're opening a URL since `application(_:openFile:)` is called before this.
|
||||
// - if we're restoring from persisted state
|
||||
if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
|
||||
terminalManager.newWindow()
|
||||
if TerminalController.all.isEmpty && derivedConfig.initialWindow {
|
||||
undoManager.disableUndoRegistration()
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
undoManager.enableUndoRegistration()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +289,7 @@ class AppDelegate: NSObject,
|
|||
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
|
||||
// here because I don't want to remove it in a patch release cycle but we should
|
||||
// target removing it soon.
|
||||
if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) {
|
||||
if (windows.allSatisfy { !$0.isVisible }) {
|
||||
return .terminateNow
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +336,13 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// We have no notifications we want to persist after death,
|
||||
// so remove them all now. In the future we may want to be
|
||||
// more selective and only remove surface-targeted notifications.
|
||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
/// This is called when the application is already open and someone double-clicks the icon
|
||||
/// or clicks the dock icon.
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
|
|
@ -327,10 +354,15 @@ class AppDelegate: NSObject,
|
|||
// This is possible with flag set to false if there a race where the
|
||||
// window is still initializing and is not visible but the user clicked
|
||||
// the dock icon.
|
||||
guard terminalManager.windows.count == 0 else { return true }
|
||||
guard TerminalController.all.isEmpty else { return true }
|
||||
|
||||
// If the application isn't active yet then we don't want to process
|
||||
// this because we're not ready. This happens sometimes in Xcode runs
|
||||
// but I haven't seen it happen in releases. I'm unsure why.
|
||||
guard applicationHasBecomeActive else { return true }
|
||||
|
||||
// No visible windows, open a new one.
|
||||
terminalManager.newWindow()
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -346,16 +378,24 @@ class AppDelegate: NSObject,
|
|||
var config = Ghostty.SurfaceConfiguration()
|
||||
|
||||
if (isDirectory.boolValue) {
|
||||
// When opening a directory, create a new tab in the main window with that as the working directory.
|
||||
// When opening a directory, create a new tab in the main
|
||||
// window with that as the working directory.
|
||||
// If no windows exist, a new one will be created.
|
||||
config.workingDirectory = filename
|
||||
terminalManager.newTab(withBaseConfig: config)
|
||||
_ = TerminalController.newTab(ghostty, withBaseConfig: config)
|
||||
} else {
|
||||
// When opening a file, open a new window with that file as the command,
|
||||
// and its parent directory as the working directory.
|
||||
config.command = filename
|
||||
// When opening a file, we want to execute the file. To do this, we
|
||||
// don't override the command directly, because it won't load the
|
||||
// profile/rc files for the shell, which is super important on macOS
|
||||
// due to things like Homebrew. Instead, we set the command to
|
||||
// `<filename>; exit` which is what Terminal and iTerm2 do.
|
||||
config.initialInput = "\(filename); exit\n"
|
||||
|
||||
// Set the parent directory to our working directory so that relative
|
||||
// paths in scripts work.
|
||||
config.workingDirectory = (filename as NSString).deletingLastPathComponent
|
||||
terminalManager.newWindow(withBaseConfig: config)
|
||||
|
||||
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -366,6 +406,46 @@ class AppDelegate: NSObject,
|
|||
return dockMenu
|
||||
}
|
||||
|
||||
/// Setup all the images for our menu items.
|
||||
private func setupMenuImages() {
|
||||
// Note: This COULD Be done all in the xib file, but I find it easier to
|
||||
// modify this stuff as code.
|
||||
self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
|
||||
self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
|
||||
self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
|
||||
self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
|
||||
self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
|
||||
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
|
||||
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
|
||||
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
|
||||
self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
|
||||
self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
|
||||
self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
|
||||
self.menuClose?.setImageIfDesired(systemSymbolName: "xmark")
|
||||
self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger")
|
||||
self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size")
|
||||
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.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
|
||||
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
|
||||
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
|
||||
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
|
||||
self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2")
|
||||
self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2")
|
||||
self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle")
|
||||
self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left")
|
||||
self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right")
|
||||
self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up")
|
||||
self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down")
|
||||
self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line")
|
||||
self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line")
|
||||
self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line")
|
||||
self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line")
|
||||
self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.3.layers.3d.top.filled")
|
||||
}
|
||||
|
||||
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
|
||||
private func syncMenuShortcuts(_ config: Ghostty.Config) {
|
||||
guard ghostty.readiness == .ready else { return }
|
||||
|
|
@ -386,6 +466,8 @@ class AppDelegate: NSObject,
|
|||
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
|
||||
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
|
||||
|
||||
syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
|
||||
syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
|
||||
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
|
||||
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
|
|
@ -442,10 +524,6 @@ class AppDelegate: NSObject,
|
|||
menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers)
|
||||
}
|
||||
|
||||
private func focusedSurface() -> ghostty_surface_t? {
|
||||
return terminalManager.focusedSurface?.surface
|
||||
}
|
||||
|
||||
// MARK: Notifications and Events
|
||||
|
||||
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
|
||||
|
|
@ -530,11 +608,13 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@objc private func ghosttyBellDidRing(_ notification: Notification) {
|
||||
// Bounce the dock icon if we're not focused.
|
||||
NSApp.requestUserAttention(.informationalRequest)
|
||||
if (ghostty.config.bellFeatures.contains(.attention)) {
|
||||
// Bounce the dock icon if we're not focused.
|
||||
NSApp.requestUserAttention(.informationalRequest)
|
||||
|
||||
// Handle setting the dock badge based on permissions
|
||||
ghosttyUpdateBadgeForBell()
|
||||
// Handle setting the dock badge based on permissions
|
||||
ghosttyUpdateBadgeForBell()
|
||||
}
|
||||
}
|
||||
|
||||
private func ghosttyUpdateBadgeForBell() {
|
||||
|
|
@ -576,6 +656,26 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyNewWindow(_ notification: Notification) {
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
||||
}
|
||||
|
||||
@objc private func ghosttyNewTab(_ notification: Notification) {
|
||||
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let window = surfaceView.window else { return }
|
||||
|
||||
// We only want to listen to new tabs if the focused parent is
|
||||
// a regular terminal controller.
|
||||
guard window.windowController is TerminalController else { return }
|
||||
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
|
||||
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
|
||||
}
|
||||
|
||||
private func setDockBadge(_ label: String? = "•") {
|
||||
NSApp.dockTile.badgeLabel = label
|
||||
NSApp.dockTile.display()
|
||||
|
|
@ -611,7 +711,7 @@ class AppDelegate: NSObject,
|
|||
|
||||
// Config could change keybindings, so update everything that depends on that
|
||||
syncMenuShortcuts(config)
|
||||
terminalManager.relabelAllTabs()
|
||||
TerminalController.all.forEach { $0.relabelTabs() }
|
||||
|
||||
// Config could change window appearance. We wrap this in an async queue because when
|
||||
// this is called as part of application launch it can deadlock with an internal
|
||||
|
|
@ -740,9 +840,11 @@ class AppDelegate: NSObject,
|
|||
//MARK: - GhosttyAppDelegate
|
||||
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
|
||||
for c in terminalManager.windows {
|
||||
if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) {
|
||||
return v
|
||||
for c in TerminalController.all {
|
||||
for view in c.surfaceTree {
|
||||
if view.uuid == uuid {
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -793,7 +895,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func newWindow(_ sender: Any?) {
|
||||
terminalManager.newWindow()
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
|
||||
// We also activate our app so that it becomes front. This may be
|
||||
// necessary for the dock menu.
|
||||
|
|
@ -801,7 +903,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func newTab(_ sender: Any?) {
|
||||
terminalManager.newTab()
|
||||
_ = TerminalController.newTab(ghostty)
|
||||
|
||||
// We also activate our app so that it becomes front. This may be
|
||||
// necessary for the dock menu.
|
||||
|
|
@ -809,7 +911,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func closeAllWindows(_ sender: Any?) {
|
||||
terminalManager.closeAllWindows()
|
||||
TerminalController.closeAllWindows()
|
||||
AboutController.shared.hide()
|
||||
}
|
||||
|
||||
|
|
@ -827,14 +929,6 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
@IBAction func toggleQuickTerminal(_ sender: Any) {
|
||||
if quickController == nil {
|
||||
quickController = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition
|
||||
)
|
||||
}
|
||||
|
||||
guard let quickController = self.quickController else { return }
|
||||
quickController.toggle()
|
||||
}
|
||||
|
||||
|
|
@ -871,6 +965,14 @@ class AppDelegate: NSObject,
|
|||
NSApplication.shared.arrangeInFront(sender)
|
||||
}
|
||||
|
||||
@IBAction func undo(_ sender: Any?) {
|
||||
undoManager.undo()
|
||||
}
|
||||
|
||||
@IBAction func redo(_ sender: Any?) {
|
||||
undoManager.redo()
|
||||
}
|
||||
|
||||
private struct DerivedConfig {
|
||||
let initialWindow: Bool
|
||||
let shouldQuitAfterLastWindowClosed: Bool
|
||||
|
|
@ -960,6 +1062,22 @@ extension AppDelegate: NSMenuItemValidation {
|
|||
// terminal window (not quick terminal).
|
||||
return NSApp.keyWindow is TerminalWindow
|
||||
|
||||
case #selector(undo(_:)):
|
||||
if undoManager.canUndo {
|
||||
item.title = "Undo \(undoManager.undoActionName)"
|
||||
} else {
|
||||
item.title = "Undo"
|
||||
}
|
||||
return undoManager.canUndo
|
||||
|
||||
case #selector(redo(_:)):
|
||||
if undoManager.canRedo {
|
||||
item.title = "Redo \(undoManager.redoActionName)"
|
||||
} else {
|
||||
item.title = "Redo"
|
||||
}
|
||||
return undoManager.canRedo
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23727" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23727"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<customObject id="bbz-4X-AYv" userLabel="AppDelegate" customClass="AppDelegate" customModule="Ghostty" customModuleProvider="target">
|
||||
<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="menuChangeTitle" destination="24I-xg-qIq" id="kg6-kT-jNL"/>
|
||||
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
|
||||
|
|
@ -40,6 +41,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="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"/>
|
||||
<outlet property="menuReturnToDefaultSize" destination="Gbx-Vi-OGC" id="po9-qC-Iz6"/>
|
||||
|
|
@ -57,6 +59,7 @@
|
|||
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
|
||||
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
|
||||
<outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/>
|
||||
<outlet property="menuUndo" destination="r83-CV-syt" id="bU9-0b-xgQ"/>
|
||||
<outlet property="menuUseAsDefault" destination="TrB-O8-g8H" id="af4-Jh-2HU"/>
|
||||
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
|
||||
</connections>
|
||||
|
|
@ -204,6 +207,19 @@
|
|||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Edit" id="iU4-OB-ccf">
|
||||
<items>
|
||||
<menuItem title="Undo" id="r83-CV-syt">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="undo:" target="-1" id="jrW-j3-OZj"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Redo" id="EX8-lB-4s7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="redo:" target="-1" id="7UK-Hj-s4O"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="4O9-zO-zB9"/>
|
||||
<menuItem title="Copy" id="Jqf-pv-Zcu">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
|
|
@ -236,18 +252,18 @@
|
|||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="View" id="m6z-2H-VW7">
|
||||
<items>
|
||||
<menuItem title="Increase Font Size" id="CIH-ey-Z6x" userLabel="Increase Font Size">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="increaseFontSize:" target="-1" id="361-5E-7PY"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Reset Font Size" id="Jah-MY-aLX">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="resetFontSize:" target="-1" id="3dh-T9-IkH"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Increase Font Size" id="CIH-ey-Z6x" userLabel="Increase Font Size">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="increaseFontSize:" target="-1" id="361-5E-7PY"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Decrease Font Size" id="kzb-SZ-dOA">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
import GhosttyKit
|
||||
|
||||
struct CloseTerminalIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Close Terminal"
|
||||
static var description = IntentDescription("Close an existing terminal.")
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to close.",
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = .background
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surfaceView = terminal.surfaceView else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
|
||||
return .result()
|
||||
}
|
||||
|
||||
controller.closeSurface(surfaceView, withConfirmation: false)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
|
||||
/// App intent that invokes a command palette entry.
|
||||
@available(macOS 14.0, *)
|
||||
struct CommandPaletteIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Invoke Command Palette Action"
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to base available commands from."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@Parameter(
|
||||
title: "Command",
|
||||
description: "The command to invoke.",
|
||||
optionsProvider: CommandQuery()
|
||||
)
|
||||
var command: CommandEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = .background
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
let performed = surface.perform(action: command.action)
|
||||
return .result(value: performed)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import AppIntents
|
||||
|
||||
// MARK: AppEntity
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
struct CommandEntity: AppEntity {
|
||||
let id: ID
|
||||
|
||||
// Note: for macOS 26 we can move all the properties to @ComputedProperty.
|
||||
|
||||
@Property(title: "Title")
|
||||
var title: String
|
||||
|
||||
@Property(title: "Description")
|
||||
var description: String
|
||||
|
||||
@Property(title: "Action")
|
||||
var action: String
|
||||
|
||||
/// The underlying data model
|
||||
let command: Ghostty.Command
|
||||
|
||||
/// A command identifier is a composite key based on the terminal and action.
|
||||
struct ID: Hashable {
|
||||
let terminalId: TerminalEntity.ID
|
||||
let actionKey: String
|
||||
|
||||
init(terminalId: TerminalEntity.ID, actionKey: String) {
|
||||
self.terminalId = terminalId
|
||||
self.actionKey = actionKey
|
||||
}
|
||||
}
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||
TypeDisplayRepresentation(name: "Command Palette Command")
|
||||
}
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(
|
||||
title: LocalizedStringResource(stringLiteral: command.title),
|
||||
subtitle: LocalizedStringResource(stringLiteral: command.description),
|
||||
)
|
||||
}
|
||||
|
||||
static var defaultQuery = CommandQuery()
|
||||
|
||||
init(_ command: Ghostty.Command, for terminal: TerminalEntity) {
|
||||
self.id = .init(terminalId: terminal.id, actionKey: command.actionKey)
|
||||
self.command = command
|
||||
self.title = command.title
|
||||
self.description = command.description
|
||||
self.action = command.action
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
extension CommandEntity.ID: RawRepresentable {
|
||||
var rawValue: String {
|
||||
return "\(terminalId):\(actionKey)"
|
||||
}
|
||||
|
||||
init?(rawValue: String) {
|
||||
let components = rawValue.split(separator: ":", maxSplits: 1)
|
||||
guard components.count == 2 else { return nil }
|
||||
|
||||
guard let terminalId = TerminalEntity.ID(uuidString: String(components[0])) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.terminalId = terminalId
|
||||
self.actionKey = String(components[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Required by AppEntity
|
||||
@available(macOS 14.0, *)
|
||||
extension CommandEntity.ID: EntityIdentifierConvertible {
|
||||
static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? {
|
||||
.init(rawValue: entityIdentifierString)
|
||||
}
|
||||
|
||||
var entityIdentifierString: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: EntityQuery
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
struct CommandQuery: EntityQuery {
|
||||
// Inject our terminal parameter from our command palette intent.
|
||||
@IntentParameterDependency<CommandPaletteIntent>(\.$terminal)
|
||||
var commandPaletteIntent
|
||||
|
||||
@MainActor
|
||||
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
|
||||
// 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] =
|
||||
terminals.reduce(into: [:]) { result, terminal in
|
||||
guard let commands = try? terminal.surfaceModel?.commands() else { return }
|
||||
result[terminal.id] = (terminal: terminal, commands: commands)
|
||||
}
|
||||
|
||||
// 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],
|
||||
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CommandEntity(command, for: terminal)
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalEntity: AppEntity {
|
||||
let id: UUID
|
||||
|
||||
@Property(title: "Title")
|
||||
var title: String
|
||||
|
||||
@Property(title: "Working Directory")
|
||||
var workingDirectory: String?
|
||||
|
||||
@Property(title: "Kind")
|
||||
var kind: Kind
|
||||
|
||||
@MainActor
|
||||
@DeferredProperty(title: "Full Contents")
|
||||
@available(macOS 26.0, *)
|
||||
var screenContents: String? {
|
||||
get async {
|
||||
guard let surfaceView else { return nil }
|
||||
return surfaceView.cachedScreenContents.get()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@DeferredProperty(title: "Visible Contents")
|
||||
@available(macOS 26.0, *)
|
||||
var visibleContents: String? {
|
||||
get async {
|
||||
guard let surfaceView else { return nil }
|
||||
return surfaceView.cachedVisibleContents.get()
|
||||
}
|
||||
}
|
||||
|
||||
var screenshot: Image?
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||
TypeDisplayRepresentation(name: "Terminal")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
var rep = DisplayRepresentation(title: "\(title)")
|
||||
if let screenshot,
|
||||
let nsImage = ImageRenderer(content: screenshot).nsImage,
|
||||
let data = nsImage.tiffRepresentation {
|
||||
rep.image = .init(data: data)
|
||||
}
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
/// Returns the view associated with this entity. This may no longer exist.
|
||||
@MainActor
|
||||
var surfaceView: Ghostty.SurfaceView? {
|
||||
Self.defaultQuery.all.first { $0.uuid == self.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var surfaceModel: Ghostty.Surface? {
|
||||
surfaceView?.surfaceModel
|
||||
}
|
||||
|
||||
static var defaultQuery = TerminalQuery()
|
||||
|
||||
init(_ view: Ghostty.SurfaceView) {
|
||||
self.id = view.uuid
|
||||
self.title = view.title
|
||||
self.workingDirectory = view.pwd
|
||||
self.screenshot = view.screenshot()
|
||||
|
||||
// Determine the kind based on the window controller type
|
||||
if view.window?.windowController is QuickTerminalController {
|
||||
self.kind = .quick
|
||||
} else {
|
||||
self.kind = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalEntity {
|
||||
enum Kind: String, AppEnum {
|
||||
case normal
|
||||
case quick
|
||||
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind")
|
||||
|
||||
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||
.normal: .init(title: "Normal"),
|
||||
.quick: .init(title: "Quick")
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
|
||||
@MainActor
|
||||
func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
|
||||
return all.filter {
|
||||
identifiers.contains($0.uuid)
|
||||
}.map {
|
||||
TerminalEntity($0)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func entities(matching string: String) async throws -> [TerminalEntity] {
|
||||
return all.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(string)
|
||||
}.map {
|
||||
TerminalEntity($0)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func allEntities() async throws -> [TerminalEntity] {
|
||||
return all.map { TerminalEntity($0) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func suggestedEntities() async throws -> [TerminalEntity] {
|
||||
return try await allEntities()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var all: [Ghostty.SurfaceView] {
|
||||
// Find all of our terminal windows. This will include the quick terminal
|
||||
// but only if it was previously opened.
|
||||
let controllers = NSApp.windows.compactMap {
|
||||
$0.windowController as? BaseTerminalController
|
||||
}
|
||||
|
||||
// Get all our surfaces
|
||||
return controllers.flatMap {
|
||||
$0.surfaceTree.root?.leaves() ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
|
||||
/// App intent that retrieves details about a specific terminal.
|
||||
struct GetTerminalDetailsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Get Details of Terminal"
|
||||
|
||||
@Parameter(
|
||||
title: "Detail",
|
||||
description: "The detail to extract about a terminal."
|
||||
)
|
||||
var detail: TerminalDetail
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to extract information about."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = .background
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Get \(\.$detail) from \(\.$terminal)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String?> {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
switch detail {
|
||||
case .title: return .result(value: terminal.title)
|
||||
case .workingDirectory: return .result(value: terminal.workingDirectory)
|
||||
case .allContents:
|
||||
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||
return .result(value: view.cachedScreenContents.get())
|
||||
case .selectedText:
|
||||
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||
return .result(value: view.accessibilitySelectedText())
|
||||
case .visibleText:
|
||||
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||
return .result(value: view.cachedVisibleContents.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: TerminalDetail
|
||||
|
||||
enum TerminalDetail: String {
|
||||
case title
|
||||
case workingDirectory
|
||||
case allContents
|
||||
case selectedText
|
||||
case visibleText
|
||||
}
|
||||
|
||||
extension TerminalDetail: AppEnum {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail")
|
||||
|
||||
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||
.title: .init(title: "Title"),
|
||||
.workingDirectory: .init(title: "Working Directory"),
|
||||
.allContents: .init(title: "Full Contents"),
|
||||
.selectedText: .init(title: "Selected Text"),
|
||||
.visibleText: .init(title: "Visible Text"),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case appUnavailable
|
||||
case surfaceNotFound
|
||||
case permissionDenied
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .appUnavailable: "The Ghostty app isn't properly initialized."
|
||||
case .surfaceNotFound: "The terminal no longer exists."
|
||||
case .permissionDenied: "Ghostty doesn't allow Shortcuts."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
|
||||
/// App intent to input text in a terminal.
|
||||
struct InputTextIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Input Text to Terminal"
|
||||
|
||||
@Parameter(
|
||||
title: "Text",
|
||||
description: "The text to input to the terminal. The text will be inputted as if it was pasted.",
|
||||
inputOptions: String.IntentInputOptions(
|
||||
capitalizationType: .none,
|
||||
multiline: true,
|
||||
autocorrect: false,
|
||||
smartQuotes: false,
|
||||
smartDashes: false
|
||||
)
|
||||
)
|
||||
var text: String
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to scope this action to."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
surface.sendText(text)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// App intent to trigger a keyboard event.
|
||||
struct KeyEventIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Keyboard Event to Terminal"
|
||||
static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.")
|
||||
|
||||
@Parameter(
|
||||
title: "Key",
|
||||
description: "The key to send to the terminal.",
|
||||
default: .enter
|
||||
)
|
||||
var key: Ghostty.Input.Key
|
||||
|
||||
@Parameter(
|
||||
title: "Modifier(s)",
|
||||
description: "The modifiers to send with the key event.",
|
||||
default: []
|
||||
)
|
||||
var mods: [KeyEventMods]
|
||||
|
||||
@Parameter(
|
||||
title: "Event Type",
|
||||
description: "A key press or release.",
|
||||
default: .press
|
||||
)
|
||||
var action: Ghostty.Input.Action
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to scope this action to."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||
result.union(mod.ghosttyMod)
|
||||
}
|
||||
|
||||
let keyEvent = Ghostty.Input.KeyEvent(
|
||||
key: key,
|
||||
action: action,
|
||||
mods: ghosttyMods
|
||||
)
|
||||
surface.sendKeyEvent(keyEvent)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: MouseButtonIntent
|
||||
|
||||
/// App intent to trigger a mouse button event.
|
||||
struct MouseButtonIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Mouse Button Event to Terminal"
|
||||
|
||||
@Parameter(
|
||||
title: "Button",
|
||||
description: "The mouse button to press or release.",
|
||||
default: .left
|
||||
)
|
||||
var button: Ghostty.Input.MouseButton
|
||||
|
||||
@Parameter(
|
||||
title: "Action",
|
||||
description: "Whether to press or release the button.",
|
||||
default: .press
|
||||
)
|
||||
var action: Ghostty.Input.MouseState
|
||||
|
||||
@Parameter(
|
||||
title: "Modifier(s)",
|
||||
description: "The modifiers to send with the mouse event.",
|
||||
default: []
|
||||
)
|
||||
var mods: [KeyEventMods]
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to scope this action to."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||
result.union(mod.ghosttyMod)
|
||||
}
|
||||
|
||||
let mouseEvent = Ghostty.Input.MouseButtonEvent(
|
||||
action: action,
|
||||
button: button,
|
||||
mods: ghosttyMods
|
||||
)
|
||||
surface.sendMouseButton(mouseEvent)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// App intent to send a mouse position event.
|
||||
struct MousePosIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Mouse Position Event to Terminal"
|
||||
static var description = IntentDescription("Send a mouse position event to the terminal. This reports the cursor position for mouse tracking.")
|
||||
|
||||
@Parameter(
|
||||
title: "X Position",
|
||||
description: "The horizontal position of the mouse cursor in pixels.",
|
||||
default: 0
|
||||
)
|
||||
var x: Double
|
||||
|
||||
@Parameter(
|
||||
title: "Y Position",
|
||||
description: "The vertical position of the mouse cursor in pixels.",
|
||||
default: 0
|
||||
)
|
||||
var y: Double
|
||||
|
||||
@Parameter(
|
||||
title: "Modifier(s)",
|
||||
description: "The modifiers to send with the mouse position event.",
|
||||
default: []
|
||||
)
|
||||
var mods: [KeyEventMods]
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to scope this action to."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||
result.union(mod.ghosttyMod)
|
||||
}
|
||||
|
||||
let mousePosEvent = Ghostty.Input.MousePosEvent(
|
||||
x: x,
|
||||
y: y,
|
||||
mods: ghosttyMods
|
||||
)
|
||||
surface.sendMousePos(mousePosEvent)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// App intent to send a mouse scroll event.
|
||||
struct MouseScrollIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Mouse Scroll Event to Terminal"
|
||||
static var description = IntentDescription("Send a mouse scroll event to the terminal with configurable precision and momentum.")
|
||||
|
||||
@Parameter(
|
||||
title: "X Scroll Delta",
|
||||
description: "The horizontal scroll amount.",
|
||||
default: 0
|
||||
)
|
||||
var x: Double
|
||||
|
||||
@Parameter(
|
||||
title: "Y Scroll Delta",
|
||||
description: "The vertical scroll amount.",
|
||||
default: 0
|
||||
)
|
||||
var y: Double
|
||||
|
||||
@Parameter(
|
||||
title: "High Precision",
|
||||
description: "Whether this is a high-precision scroll event (e.g., from trackpad).",
|
||||
default: false
|
||||
)
|
||||
var precision: Bool
|
||||
|
||||
@Parameter(
|
||||
title: "Momentum Phase",
|
||||
description: "The momentum phase for inertial scrolling.",
|
||||
default: Ghostty.Input.Momentum.none
|
||||
)
|
||||
var momentum: Ghostty.Input.Momentum
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to scope this action to."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
let scrollEvent = Ghostty.Input.MouseScrollEvent(
|
||||
x: x,
|
||||
y: y,
|
||||
mods: .init(precision: precision, momentum: momentum)
|
||||
)
|
||||
surface.sendMouseScroll(scrollEvent)
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Mods
|
||||
|
||||
enum KeyEventMods: String, AppEnum, CaseIterable {
|
||||
case shift
|
||||
case control
|
||||
case option
|
||||
case command
|
||||
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key")
|
||||
|
||||
static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [
|
||||
.shift: "Shift",
|
||||
.control: "Control",
|
||||
.option: "Option",
|
||||
.command: "Command"
|
||||
]
|
||||
|
||||
var ghosttyMod: Ghostty.Input.Mods {
|
||||
switch self {
|
||||
case .shift: .shift
|
||||
case .control: .ctrl
|
||||
case .option: .alt
|
||||
case .command: .super
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import AppKit
|
||||
|
||||
/// Requests permission for Shortcuts app to interact with Ghostty
|
||||
///
|
||||
/// This function displays a permission dialog asking the user to allow Shortcuts
|
||||
/// to interact with Ghostty. The permission is automatically cached for 10 minutes
|
||||
/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog
|
||||
/// again during that time period.
|
||||
///
|
||||
/// The permission uses a shared UserDefaults key across all intents, so granting
|
||||
/// permission for one intent allows all Ghostty intents to execute without additional
|
||||
/// prompts for the duration of the cache period.
|
||||
///
|
||||
/// - Returns: `true` if permission is granted, `false` if denied
|
||||
///
|
||||
/// ## Usage
|
||||
/// Add this check at the beginning of any App Intent's `perform()` method:
|
||||
/// ```swift
|
||||
/// @MainActor
|
||||
/// func perform() async throws -> some IntentResult {
|
||||
/// guard await requestIntentPermission() else {
|
||||
/// throw GhosttyIntentError.permissionDenied
|
||||
/// }
|
||||
/// // ... continue with intent implementation
|
||||
/// }
|
||||
/// ```
|
||||
func requestIntentPermission() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
Task { @MainActor in
|
||||
if let delegate = NSApp.delegate as? AppDelegate {
|
||||
switch (delegate.ghostty.config.macosShortcuts) {
|
||||
case .allow:
|
||||
continuation.resume(returning: true)
|
||||
return
|
||||
|
||||
case .deny:
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
|
||||
case .ask:
|
||||
// Continue with the permission dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
PermissionRequest.show(
|
||||
"com.mitchellh.ghostty.shortcutsPermission",
|
||||
message: "Allow Shortcuts to interact with Ghostty?",
|
||||
allowDuration: .forever,
|
||||
rememberDuration: nil,
|
||||
) { response in
|
||||
continuation.resume(returning: response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
|
||||
struct KeybindIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Invoke a Keybind Action"
|
||||
|
||||
@Parameter(
|
||||
title: "Terminal",
|
||||
description: "The terminal to invoke the action on."
|
||||
)
|
||||
var terminal: TerminalEntity
|
||||
|
||||
@Parameter(
|
||||
title: "Action",
|
||||
description: "The keybind action to invoke. This can be any valid keybind action you could put in a configuration file."
|
||||
)
|
||||
var action: String
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = [.background, .foreground]
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
let performed = surface.perform(action: action)
|
||||
return .result(value: performed)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
import GhosttyKit
|
||||
|
||||
/// App intent that allows creating a new terminal window or tab.
|
||||
///
|
||||
/// This requires macOS 15 or greater because we use features of macOS 15 here.
|
||||
@available(macOS 15.0, *)
|
||||
struct NewTerminalIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "New Terminal"
|
||||
static var description = IntentDescription("Create a new terminal.")
|
||||
|
||||
@Parameter(
|
||||
title: "Location",
|
||||
description: "The location that the terminal should be created.",
|
||||
default: .window
|
||||
)
|
||||
var location: NewTerminalLocation
|
||||
|
||||
@Parameter(
|
||||
title: "Command",
|
||||
description: "Command to execute within your configured shell.",
|
||||
)
|
||||
var command: String?
|
||||
|
||||
@Parameter(
|
||||
title: "Working Directory",
|
||||
description: "The working directory to open in the terminal.",
|
||||
supportedContentTypes: [.folder]
|
||||
)
|
||||
var workingDirectory: IntentFile?
|
||||
|
||||
@Parameter(
|
||||
title: "Environment Variables",
|
||||
description: "Environment variables in `KEY=VALUE` format.",
|
||||
default: []
|
||||
)
|
||||
var env: [String]
|
||||
|
||||
@Parameter(
|
||||
title: "Parent Terminal",
|
||||
description: "The terminal to inherit the base configuration from."
|
||||
)
|
||||
var parent: TerminalEntity?
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = .foreground(.immediate)
|
||||
|
||||
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else {
|
||||
throw GhosttyIntentError.appUnavailable
|
||||
}
|
||||
let ghostty = appDelegate.ghostty
|
||||
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
|
||||
// We don't run command as "command" and instead use "initialInput" so
|
||||
// that we can get all the login scripts to setup things like PATH.
|
||||
if let command {
|
||||
config.initialInput = "\(command); exit\n"
|
||||
}
|
||||
|
||||
// If we were given a working directory then open that directory
|
||||
if let url = workingDirectory?.fileURL {
|
||||
let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
|
||||
config.workingDirectory = dir.path(percentEncoded: false)
|
||||
}
|
||||
|
||||
// Parse environment variables from KEY=VALUE format
|
||||
for envVar in env {
|
||||
if let separatorIndex = envVar.firstIndex(of: "=") {
|
||||
let key = String(envVar[..<separatorIndex])
|
||||
let value = String(envVar[envVar.index(after: separatorIndex)...])
|
||||
config.environmentVariables[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we have a parent and get it
|
||||
let parent: Ghostty.SurfaceView?
|
||||
if let parentParam = self.parent {
|
||||
guard let view = parentParam.surfaceView else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
parent = view
|
||||
} else if let preferred = TerminalController.preferredParent {
|
||||
parent = preferred.focusedSurface ?? preferred.surfaceTree.root?.leftmostLeaf()
|
||||
} else {
|
||||
parent = nil
|
||||
}
|
||||
|
||||
switch location {
|
||||
case .window:
|
||||
let newController = TerminalController.newWindow(
|
||||
ghostty,
|
||||
withBaseConfig: config,
|
||||
withParent: parent?.window)
|
||||
if let view = newController.surfaceTree.root?.leftmostLeaf() {
|
||||
return .result(value: TerminalEntity(view))
|
||||
}
|
||||
|
||||
case .tab:
|
||||
let newController = TerminalController.newTab(
|
||||
ghostty,
|
||||
from: parent?.window,
|
||||
withBaseConfig: config)
|
||||
if let view = newController?.surfaceTree.root?.leftmostLeaf() {
|
||||
return .result(value: TerminalEntity(view))
|
||||
}
|
||||
|
||||
case .splitLeft, .splitRight, .splitUp, .splitDown:
|
||||
guard let parent,
|
||||
let controller = parent.window?.windowController as? BaseTerminalController else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
if let view = controller.newSplit(
|
||||
at: parent,
|
||||
direction: location.splitDirection!
|
||||
) {
|
||||
return .result(value: TerminalEntity(view))
|
||||
}
|
||||
}
|
||||
|
||||
return .result(value: .none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NewTerminalLocation
|
||||
|
||||
enum NewTerminalLocation: String {
|
||||
case tab
|
||||
case window
|
||||
case splitLeft = "split:left"
|
||||
case splitRight = "split:right"
|
||||
case splitUp = "split:up"
|
||||
case splitDown = "split:down"
|
||||
|
||||
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection? {
|
||||
switch self {
|
||||
case .splitLeft: return .left
|
||||
case .splitRight: return .right
|
||||
case .splitUp: return .up
|
||||
case .splitDown: return .down
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NewTerminalLocation: AppEnum {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location")
|
||||
|
||||
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||
.tab: .init(title: "Tab"),
|
||||
.window: .init(title: "Window"),
|
||||
.splitLeft: .init(title: "Split Left"),
|
||||
.splitRight: .init(title: "Split Right"),
|
||||
.splitUp: .init(title: "Split Up"),
|
||||
.splitDown: .init(title: "Split Down"),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import AppKit
|
||||
import AppIntents
|
||||
|
||||
struct QuickTerminalIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open the Quick Terminal"
|
||||
static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.")
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
static var supportedModes: IntentModes = .background
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else {
|
||||
throw GhosttyIntentError.appUnavailable
|
||||
}
|
||||
|
||||
// This is safe to call even if it is already shown.
|
||||
let c = delegate.quickController
|
||||
c.animateIn()
|
||||
|
||||
// Grab all our terminals
|
||||
let terminals = c.surfaceTree.root?.leaves().map {
|
||||
TerminalEntity($0)
|
||||
} ?? []
|
||||
|
||||
return .result(value: terminals)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,26 @@ extension View {
|
|||
/// Returns the ghostty icon to use for views.
|
||||
func ghosttyIconImage() -> Image {
|
||||
#if os(macOS)
|
||||
// If we have a specific icon set, then use that
|
||||
if let delegate = NSApplication.shared.delegate as? AppDelegate,
|
||||
let nsImage = delegate.appIcon {
|
||||
return Image(nsImage: nsImage)
|
||||
}
|
||||
|
||||
// Grab the icon from the running application. This is the best way
|
||||
// I've found so far to get the proper icon for our current icon
|
||||
// tinting and so on with macOS Tahoe
|
||||
if let icon = NSRunningApplication.current.icon {
|
||||
return Image(nsImage: icon)
|
||||
}
|
||||
|
||||
// Get our defined application icon image.
|
||||
if let nsImage = NSApp.applicationIconImage {
|
||||
return Image(nsImage: nsImage)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Fall back to a static representation
|
||||
return Image("AppIconImage")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,32 +17,19 @@ struct TerminalCommandPaletteView: View {
|
|||
|
||||
// The commands available to the command palette.
|
||||
private var commandOptions: [CommandOption] {
|
||||
guard let surface = surfaceView.surface else { return [] }
|
||||
|
||||
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
|
||||
var count: Int = 0
|
||||
ghostty_surface_commands(surface, &ptr, &count)
|
||||
guard let ptr else { return [] }
|
||||
|
||||
let buffer = UnsafeBufferPointer(start: ptr, count: count)
|
||||
return Array(buffer).filter { c in
|
||||
let key = String(cString: c.action_key)
|
||||
switch (key) {
|
||||
case "toggle_tab_overview",
|
||||
"toggle_window_decorations":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}.map { c in
|
||||
let action = String(cString: c.action)
|
||||
return CommandOption(
|
||||
title: String(cString: c.title),
|
||||
description: String(cString: c.description),
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList
|
||||
) {
|
||||
onAction(action)
|
||||
guard let surface = surfaceView.surfaceModel else { return [] }
|
||||
do {
|
||||
return try surface.commands().map { c in
|
||||
return CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,12 +141,7 @@ fileprivate func cgEventFlagsChangedHandler(
|
|||
guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result }
|
||||
|
||||
// Build our event input and call ghostty
|
||||
var key_ev = ghostty_input_key_s()
|
||||
key_ev.action = GHOSTTY_ACTION_PRESS
|
||||
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
key_ev.keycode = UInt32(event.keyCode)
|
||||
key_ev.text = nil
|
||||
key_ev.composing = false
|
||||
let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
if (ghostty_app_key(ghostty, key_ev)) {
|
||||
GlobalEventTap.logger.info("global key event handled event=\(event)")
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ class QuickTerminalController: BaseTerminalController {
|
|||
// The active space when the quick terminal was last shown.
|
||||
private var previousActiveSpace: CGSSpace? = nil
|
||||
|
||||
/// The window frame saved when the quick terminal's surface tree becomes empty.
|
||||
///
|
||||
/// This preserves the user's window size and position when all terminal surfaces
|
||||
/// are closed (e.g., via the `exit` command). When a new surface is created,
|
||||
/// the window will be restored to this frame, preventing SwiftUI from resetting
|
||||
/// the window to its default minimum size.
|
||||
private var lastClosedFrame: NSRect? = nil
|
||||
|
||||
/// Non-nil if we have hidden dock state.
|
||||
private var hiddenDock: HiddenDock? = nil
|
||||
|
||||
|
|
@ -30,11 +38,15 @@ class QuickTerminalController: BaseTerminalController {
|
|||
init(_ ghostty: Ghostty.App,
|
||||
position: QuickTerminalPosition = .top,
|
||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
surfaceTree tree: Ghostty.SplitNode? = nil
|
||||
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
) {
|
||||
self.position = position
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
||||
|
||||
// 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`.
|
||||
super.init(ghostty, baseConfig: base, surfaceTree: .init())
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
|
|
@ -53,6 +65,12 @@ class QuickTerminalController: BaseTerminalController {
|
|||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(closeWindow(_:)),
|
||||
name: .ghosttyCloseWindow,
|
||||
object: nil
|
||||
)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(onNewTab),
|
||||
|
|
@ -185,13 +203,51 @@ class QuickTerminalController: BaseTerminalController {
|
|||
|
||||
// MARK: Base Controller Overrides
|
||||
|
||||
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
|
||||
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
super.surfaceTreeDidChange(from: from, to: to)
|
||||
|
||||
// If our surface tree is nil then we animate the window out.
|
||||
if (to == nil) {
|
||||
// If our surface tree is nil then we animate the window out. We
|
||||
// defer reinitializing the tree to save some memory here.
|
||||
if to.isEmpty {
|
||||
animateOut()
|
||||
return
|
||||
}
|
||||
|
||||
// If we're not empty (e.g. this isn't the first set) and we're
|
||||
// not visible, then we animate in. This allows us to show the quick
|
||||
// terminal when things such as undo/redo are done.
|
||||
if !from.isEmpty && !visible {
|
||||
animateIn()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override func closeSurface(
|
||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||
withConfirmation: Bool = true
|
||||
) {
|
||||
// If this isn't the root then we're dealing with a split closure.
|
||||
if surfaceTree.root != node {
|
||||
super.closeSurface(node, withConfirmation: withConfirmation)
|
||||
return
|
||||
}
|
||||
|
||||
// If this isn't a final leaf then we're dealing with a split closure
|
||||
guard case .leaf(let surface) = node else {
|
||||
super.closeSurface(node, withConfirmation: withConfirmation)
|
||||
return
|
||||
}
|
||||
|
||||
// If its the root, we check if the process exited. If it did,
|
||||
// then we do empty the tree.
|
||||
if surface.processExited {
|
||||
surfaceTree = .init()
|
||||
return
|
||||
}
|
||||
|
||||
// If its the root then we just animate out. We never actually allow
|
||||
// the surface to fully close.
|
||||
animateOut()
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
|
@ -230,17 +286,18 @@ class QuickTerminalController: BaseTerminalController {
|
|||
// Set previous active space
|
||||
self.previousActiveSpace = CGSSpace.active()
|
||||
|
||||
// If our surface tree is empty then we initialize a new terminal. The surface
|
||||
// tree can be empty if for example we run "exit" in the terminal and force
|
||||
// animate out.
|
||||
if surfaceTree.isEmpty,
|
||||
let ghostty_app = ghostty.app {
|
||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil)
|
||||
surfaceTree = SplitTree(view: view)
|
||||
focusedSurface = view
|
||||
}
|
||||
|
||||
// Animate the window in
|
||||
animateWindowIn(window: window, from: position)
|
||||
|
||||
// If our surface tree is nil then we initialize a new terminal. The surface
|
||||
// tree can be nil if for example we run "eixt" in the terminal and force
|
||||
// animate out.
|
||||
if (surfaceTree == nil) {
|
||||
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
|
||||
surfaceTree = .leaf(leaf)
|
||||
focusedSurface = leaf.surface
|
||||
}
|
||||
}
|
||||
|
||||
func animateOut() {
|
||||
|
|
@ -262,6 +319,12 @@ class QuickTerminalController: BaseTerminalController {
|
|||
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
||||
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
||||
|
||||
// Restore our previous frame if we have one
|
||||
if let lastClosedFrame {
|
||||
window.setFrame(lastClosedFrame, display: false)
|
||||
self.lastClosedFrame = nil
|
||||
}
|
||||
|
||||
// Move our window off screen to the top
|
||||
position.setInitial(in: window, on: screen)
|
||||
|
||||
|
|
@ -372,6 +435,12 @@ class QuickTerminalController: BaseTerminalController {
|
|||
}
|
||||
|
||||
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
|
||||
// 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.
|
||||
lastClosedFrame = window.frame
|
||||
|
||||
// If we hid the dock then we unhide it.
|
||||
hiddenDock = nil
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class ServiceProvider: NSObject {
|
|||
static private let errorNoString = NSString(string: "Could not load any text from the clipboard.")
|
||||
|
||||
/// The target for an open operation
|
||||
enum OpenTarget {
|
||||
private enum OpenTarget {
|
||||
case tab
|
||||
case window
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class ServiceProvider: NSObject {
|
|||
userData: String?,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error)
|
||||
openTerminal(from: pasteboard, target: .tab, error: error)
|
||||
}
|
||||
|
||||
@objc func openWindow(
|
||||
|
|
@ -23,47 +23,39 @@ class ServiceProvider: NSObject {
|
|||
userData: String?,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
openTerminalFromPasteboard(pasteboard: pasteboard, target: .window, error: error)
|
||||
openTerminal(from: pasteboard, target: .window, error: error)
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func openTerminalFromPasteboard(
|
||||
pasteboard: NSPasteboard,
|
||||
private func openTerminal(
|
||||
from pasteboard: NSPasteboard,
|
||||
target: OpenTarget,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
guard let objs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [NSURL] else {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else { return }
|
||||
|
||||
guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
|
||||
error.pointee = Self.errorNoString
|
||||
return
|
||||
}
|
||||
let urlObjects = objs.map { $0 as URL }
|
||||
|
||||
openTerminal(urlObjects, target: target)
|
||||
}
|
||||
|
||||
private func openTerminal(_ urls: [URL], target: OpenTarget) {
|
||||
guard let delegateRaw = NSApp.delegate else { return }
|
||||
guard let delegate = delegateRaw as? AppDelegate else { return }
|
||||
let terminalManager = delegate.terminalManager
|
||||
|
||||
let uniqueCwds: Set<URL> = Set(
|
||||
urls.map { url -> URL in
|
||||
// We only open in directories.
|
||||
// Build a set of unique directory URLs to open. File paths are truncated
|
||||
// to their directories because that's the only thing we can open.
|
||||
let directoryURLs = Set(
|
||||
pathURLs.map { url -> URL in
|
||||
url.hasDirectoryPath ? url : url.deletingLastPathComponent()
|
||||
}
|
||||
)
|
||||
|
||||
for cwd in uniqueCwds {
|
||||
// Build our config
|
||||
for url in directoryURLs {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.workingDirectory = cwd.path(percentEncoded: false)
|
||||
config.workingDirectory = url.path(percentEncoded: false)
|
||||
|
||||
switch (target) {
|
||||
case .window:
|
||||
terminalManager.newWindow(withBaseConfig: config)
|
||||
_ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
|
||||
|
||||
case .tab:
|
||||
terminalManager.newTab(withBaseConfig: config)
|
||||
_ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ extension SplitView {
|
|||
let visibleSize: CGFloat
|
||||
let invisibleSize: CGFloat
|
||||
let color: Color
|
||||
@Binding var split: CGFloat
|
||||
|
||||
private var visibleWidth: CGFloat? {
|
||||
switch (direction) {
|
||||
|
|
@ -79,6 +80,40 @@ extension SplitView {
|
|||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(axLabel)
|
||||
.accessibilityValue("\(Int(split * 100))%")
|
||||
.accessibilityHint(axHint)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityAdjustableAction { direction in
|
||||
let adjustment: CGFloat = 0.025
|
||||
switch direction {
|
||||
case .increment:
|
||||
split = min(split + adjustment, 0.9)
|
||||
case .decrement:
|
||||
split = max(split - adjustment, 0.1)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var axLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Horizontal split divider"
|
||||
case .vertical:
|
||||
return "Vertical split divider"
|
||||
}
|
||||
}
|
||||
|
||||
private var axHint: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Drag to resize the left and right panes"
|
||||
case .vertical:
|
||||
return "Drag to resize the top and bottom panes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing.
|
||||
/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom".
|
||||
|
|
@ -13,12 +12,10 @@ struct SplitView<L: View, R: View>: View {
|
|||
/// Divider color
|
||||
let dividerColor: Color
|
||||
|
||||
/// If set, the split view supports programmatic resizing via events sent via the publisher.
|
||||
/// Minimum increment (in points) that this split can be resized by, in
|
||||
/// each direction. Both `height` and `width` should be whole numbers
|
||||
/// greater than or equal to 1.0
|
||||
let resizeIncrements: NSSize
|
||||
let resizePublisher: PassthroughSubject<Double, Never>
|
||||
|
||||
/// The left and right views to render.
|
||||
let left: L
|
||||
|
|
@ -45,47 +42,32 @@ struct SplitView<L: View, R: View>: View {
|
|||
left
|
||||
.frame(width: leftRect.size.width, height: leftRect.size.height)
|
||||
.offset(x: leftRect.origin.x, y: leftRect.origin.y)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(leftPaneLabel)
|
||||
right
|
||||
.frame(width: rightRect.size.width, height: rightRect.size.height)
|
||||
.offset(x: rightRect.origin.x, y: rightRect.origin.y)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(rightPaneLabel)
|
||||
Divider(direction: direction,
|
||||
visibleSize: splitterVisibleSize,
|
||||
invisibleSize: splitterInvisibleSize,
|
||||
color: dividerColor)
|
||||
color: dividerColor,
|
||||
split: $split)
|
||||
.position(splitterPoint)
|
||||
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
|
||||
}
|
||||
.onReceive(resizePublisher) { value in
|
||||
resize(for: geo.size, amount: value)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(splitViewLabel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a split view. This view isn't programmatically resizable; it can only be resized
|
||||
/// by manually dragging the divider.
|
||||
init(_ direction: SplitViewDirection,
|
||||
_ split: Binding<CGFloat>,
|
||||
dividerColor: Color,
|
||||
@ViewBuilder left: (() -> L),
|
||||
@ViewBuilder right: (() -> R)) {
|
||||
self.init(
|
||||
direction,
|
||||
split,
|
||||
dividerColor: dividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
resizePublisher: .init(),
|
||||
left: left,
|
||||
right: right
|
||||
)
|
||||
}
|
||||
|
||||
/// Initialize a split view that supports programmatic resizing.
|
||||
/// Initialize a split view that can be resized by manually dragging the divider.
|
||||
init(
|
||||
_ direction: SplitViewDirection,
|
||||
_ split: Binding<CGFloat>,
|
||||
dividerColor: Color,
|
||||
resizeIncrements: NSSize,
|
||||
resizePublisher: PassthroughSubject<Double, Never>,
|
||||
resizeIncrements: NSSize = .init(width: 1, height: 1),
|
||||
@ViewBuilder left: (() -> L),
|
||||
@ViewBuilder right: (() -> R)
|
||||
) {
|
||||
|
|
@ -93,25 +75,10 @@ struct SplitView<L: View, R: View>: View {
|
|||
self._split = split
|
||||
self.dividerColor = dividerColor
|
||||
self.resizeIncrements = resizeIncrements
|
||||
self.resizePublisher = resizePublisher
|
||||
self.left = left()
|
||||
self.right = right()
|
||||
}
|
||||
|
||||
private func resize(for size: CGSize, amount: Double) {
|
||||
let dim: CGFloat
|
||||
switch (direction) {
|
||||
case .horizontal:
|
||||
dim = size.width
|
||||
case .vertical:
|
||||
dim = size.height
|
||||
}
|
||||
|
||||
let pos = split * dim
|
||||
let new = min(max(minSize, pos + amount), dim - minSize)
|
||||
split = new / dim
|
||||
}
|
||||
|
||||
private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture {
|
||||
return DragGesture()
|
||||
.onChanged { gesture in
|
||||
|
|
@ -177,6 +144,35 @@ struct SplitView<L: View, R: View>: View {
|
|||
return CGPoint(x: size.width / 2, y: leftRect.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
private var splitViewLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Horizontal split view"
|
||||
case .vertical:
|
||||
return "Vertical split view"
|
||||
}
|
||||
}
|
||||
|
||||
private var leftPaneLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Left pane"
|
||||
case .vertical:
|
||||
return "Top pane"
|
||||
}
|
||||
}
|
||||
|
||||
private var rightPaneLabel: String {
|
||||
switch direction {
|
||||
case .horizontal:
|
||||
return "Right pane"
|
||||
case .vertical:
|
||||
return "Bottom pane"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SplitViewDirection: Codable {
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import SwiftUI
|
||||
|
||||
struct TerminalSplitTreeView: View {
|
||||
let tree: SplitTree<Ghostty.SurfaceView>
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
if let node = tree.zoomed ?? tree.root {
|
||||
TerminalSplitSubtreeView(
|
||||
node: node,
|
||||
isRoot: node == tree.root,
|
||||
onResize: onResize)
|
||||
// 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 beaviors.
|
||||
// See: https://github.com/ghostty-org/ghostty/issues/7546
|
||||
.id(node.structuralIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
switch (node) {
|
||||
case .leaf(let leafView):
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: leafView,
|
||||
isSplit: !isRoot)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
|
||||
case .split(let split):
|
||||
let splitViewDirection: SplitViewDirection = switch (split.direction) {
|
||||
case .horizontal: .horizontal
|
||||
case .vertical: .vertical
|
||||
}
|
||||
|
||||
SplitView(
|
||||
splitViewDirection,
|
||||
.init(get: {
|
||||
CGFloat(split.ratio)
|
||||
}, set: {
|
||||
onResize(node, $0)
|
||||
}),
|
||||
dividerColor: ghostty.config.splitDividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
left: {
|
||||
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
|
||||
},
|
||||
right: {
|
||||
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,8 +41,8 @@ class BaseTerminalController: NSWindowController,
|
|||
didSet { syncFocusToSurfaceTree() }
|
||||
}
|
||||
|
||||
/// The surface tree for this window.
|
||||
@Published var surfaceTree: Ghostty.SplitNode? = nil {
|
||||
/// The tree of splits within this terminal window.
|
||||
@Published var surfaceTree: SplitTree<Ghostty.SurfaceView> = .init() {
|
||||
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +75,27 @@ class BaseTerminalController: NSWindowController,
|
|||
/// The cancellables related to our focused surface.
|
||||
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// The time that undo/redo operations that contain running ptys are valid for.
|
||||
var undoExpiration: Duration {
|
||||
ghostty.config.undoTimeout
|
||||
}
|
||||
|
||||
/// The undo manager for this controller is the undo manager of the window,
|
||||
/// which we set via the delegate method.
|
||||
override var undoManager: ExpiringUndoManager? {
|
||||
// This should be set via the delegate method windowWillReturnUndoManager
|
||||
if let result = window?.undoManager as? ExpiringUndoManager {
|
||||
return result
|
||||
}
|
||||
|
||||
// If the window one isn't set, we fallback to our global one.
|
||||
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
||||
return appDelegate.undoManager
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
struct SavedFrame {
|
||||
let window: NSRect
|
||||
let screen: NSRect
|
||||
|
|
@ -86,7 +107,7 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
surfaceTree tree: Ghostty.SplitNode? = nil
|
||||
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
) {
|
||||
self.ghostty = ghostty
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
|
@ -95,7 +116,7 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
// Initialize our initial surface.
|
||||
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
|
||||
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
|
||||
self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base))
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
|
|
@ -125,6 +146,38 @@ class BaseTerminalController: NSWindowController,
|
|||
name: .ghosttyMaximizeDidToggle,
|
||||
object: nil)
|
||||
|
||||
// Splits
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidCloseSurface(_:)),
|
||||
name: Ghostty.Notification.ghosttyCloseSurface,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidNewSplit(_:)),
|
||||
name: Ghostty.Notification.ghosttyNewSplit,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidEqualizeSplits(_:)),
|
||||
name: Ghostty.Notification.didEqualizeSplits,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidFocusSplit(_:)),
|
||||
name: Ghostty.Notification.ghosttyFocusSplit,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidToggleSplitZoom(_:)),
|
||||
name: Ghostty.Notification.didToggleSplitZoom,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidResizeSplit(_:)),
|
||||
name: Ghostty.Notification.didResizeSplit,
|
||||
object: nil)
|
||||
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
|
||||
|
|
@ -134,20 +187,58 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
undoManager?.removeAllActions(withTarget: self)
|
||||
if let eventMonitor {
|
||||
NSEvent.removeMonitor(eventMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Create a new split.
|
||||
@discardableResult
|
||||
func newSplit(
|
||||
at oldView: Ghostty.SurfaceView,
|
||||
direction: SplitTree<Ghostty.SurfaceView>.NewDirection,
|
||||
baseConfig config: Ghostty.SurfaceConfiguration? = nil
|
||||
) -> Ghostty.SurfaceView? {
|
||||
// We can only create new splits for surfaces in our tree.
|
||||
guard surfaceTree.root?.node(view: oldView) != nil else { return nil }
|
||||
|
||||
// Create a new surface view
|
||||
guard let ghostty_app = ghostty.app else { return nil }
|
||||
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||
|
||||
// Do the split
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.insert(
|
||||
view: newView,
|
||||
at: oldView,
|
||||
direction: direction)
|
||||
} catch {
|
||||
// If splitting fails for any reason (it should not), then we just log
|
||||
// and return. The new view we created will be deinitialized and its
|
||||
// no big deal.
|
||||
Ghostty.logger.warning("failed to insert split: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: oldView,
|
||||
undoAction: "New Split")
|
||||
|
||||
return newView
|
||||
}
|
||||
|
||||
/// Called when the surfaceTree variable changed.
|
||||
///
|
||||
/// Subclasses should call super first.
|
||||
func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
|
||||
// If our surface tree becomes nil then ensure all surfaces
|
||||
// in the old tree have closed.
|
||||
if (to == nil) {
|
||||
from?.close()
|
||||
func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
// If our surface tree becomes empty then we have no focused surface.
|
||||
if (to.isEmpty) {
|
||||
focusedSurface = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -155,16 +246,14 @@ class BaseTerminalController: NSWindowController,
|
|||
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
|
||||
/// what surface is focused. This must be called whenever a surface OR window changes focus.
|
||||
func syncFocusToSurfaceTree() {
|
||||
guard let tree = self.surfaceTree else { return }
|
||||
|
||||
for leaf in tree {
|
||||
for surfaceView in surfaceTree {
|
||||
// Our focus state requires that this window is key and our currently
|
||||
// focused surface is the surface in this leaf.
|
||||
// focused surface is the surface in this view.
|
||||
let focused: Bool = (window?.isKeyWindow ?? false) &&
|
||||
!commandPaletteIsShowing &&
|
||||
focusedSurface != nil &&
|
||||
leaf.surface == focusedSurface!
|
||||
leaf.surface.focusDidChange(focused)
|
||||
surfaceView == focusedSurface!
|
||||
surfaceView.focusDidChange(focused)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +266,164 @@ class BaseTerminalController: NSWindowController,
|
|||
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
|
||||
}
|
||||
|
||||
func confirmClose(
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
// If we already have an alert, we need to wait for that one.
|
||||
guard alert == nil else { return }
|
||||
|
||||
// If there is no window to attach the modal then we assume success
|
||||
// since we'll never be able to show the modal.
|
||||
guard let window else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
// If we need confirmation by any, show one confirmation for all windows
|
||||
// in the tab group.
|
||||
let alert = NSAlert()
|
||||
alert.messageText = messageText
|
||||
alert.informativeText = informativeText
|
||||
alert.addButton(withTitle: "Close")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: window) { response in
|
||||
self.alert = nil
|
||||
if response == .alertFirstButtonReturn {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// Store our alert so we only ever show one.
|
||||
self.alert = alert
|
||||
}
|
||||
|
||||
/// Close a surface from a view.
|
||||
func closeSurface(
|
||||
_ view: Ghostty.SurfaceView,
|
||||
withConfirmation: Bool = true
|
||||
) {
|
||||
guard let node = surfaceTree.root?.node(view: view) else { return }
|
||||
closeSurface(node, withConfirmation: withConfirmation)
|
||||
}
|
||||
|
||||
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
|
||||
///
|
||||
/// This will also insert the proper undo stack information in.
|
||||
func closeSurface(
|
||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||
withConfirmation: Bool = true
|
||||
) {
|
||||
// This node must be part of our tree
|
||||
guard surfaceTree.contains(node) else { return }
|
||||
|
||||
// If the child process is not alive, then we exit immediately
|
||||
guard withConfirmation else {
|
||||
removeSurfaceNode(node)
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
|
||||
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
|
||||
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
|
||||
// so SwiftUI does not update any of the bindings to note that window is no longer
|
||||
// being shown, and provides no callback to detect this.
|
||||
confirmClose(
|
||||
messageText: "Close Terminal?",
|
||||
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
||||
) { [weak self] in
|
||||
if let self {
|
||||
self.removeSurfaceNode(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Split Tree Management
|
||||
|
||||
/// Find the next surface to focus when a node is being closed.
|
||||
/// Goes to previous split unless we're the leftmost leaf, then goes to next.
|
||||
private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
|
||||
guard let root = surfaceTree.root else { return nil }
|
||||
|
||||
// If we're the leftmost, then we move to the next surface after closing.
|
||||
// Otherwise, we move to the previous.
|
||||
if root.leftmostLeaf() == node.leftmostLeaf() {
|
||||
return surfaceTree.focusTarget(for: .next, from: node)
|
||||
} else {
|
||||
return surfaceTree.focusTarget(for: .previous, from: node)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a node from the surface tree and move focus appropriately.
|
||||
///
|
||||
/// This also updates the undo manager to support restoring this node.
|
||||
///
|
||||
/// This does no confirmation and assumes confirmation is already done.
|
||||
private func removeSurfaceNode(_ node: SplitTree<Ghostty.SurfaceView>.Node) {
|
||||
// Move focus if the closed surface was focused and we have a next target
|
||||
let nextFocus: Ghostty.SurfaceView? = if node.contains(
|
||||
where: { $0 == focusedSurface }
|
||||
) {
|
||||
findNextFocusTargetAfterClosing(node: node)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
surfaceTree.remove(node),
|
||||
moveFocusTo: nextFocus,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Close Terminal"
|
||||
)
|
||||
}
|
||||
|
||||
private func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
undoAction: String? = nil
|
||||
) {
|
||||
// Setup our new split tree
|
||||
let oldTree = surfaceTree
|
||||
surfaceTree = newTree
|
||||
if let newView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: newView, from: oldView)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager {
|
||||
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: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {
|
||||
|
|
@ -239,17 +486,158 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
@objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) {
|
||||
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
|
||||
guard surfaceTree.contains(surfaceView) else { return }
|
||||
toggleCommandPalette(nil)
|
||||
}
|
||||
|
||||
@objc private func ghosttyMaximizeDidToggle(_ notification: Notification) {
|
||||
guard let window else { return }
|
||||
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
|
||||
guard surfaceTree.contains(surfaceView) else { return }
|
||||
window.zoom(nil)
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let node = surfaceTree.root?.node(view: target) else { return }
|
||||
closeSurface(
|
||||
node,
|
||||
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
|
||||
// The target must be within our tree
|
||||
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.root?.node(view: oldView) != nil else { return }
|
||||
|
||||
// Notification must contain our base config
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
|
||||
// Determine our desired direction
|
||||
guard let directionAny = notification.userInfo?["direction"] else { return }
|
||||
guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
|
||||
let splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection
|
||||
switch (direction) {
|
||||
case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
|
||||
case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
|
||||
case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down
|
||||
case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up
|
||||
default: return
|
||||
}
|
||||
|
||||
newSplit(at: oldView, direction: splitDirection, baseConfig: config)
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
|
||||
// Check if target surface is in current controller's tree
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Equalize the splits
|
||||
surfaceTree = surfaceTree.equalize()
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
|
||||
// The target must be within our tree
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.root?.node(view: target) != nil else { return }
|
||||
|
||||
// Get the direction from the notification
|
||||
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
|
||||
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
|
||||
|
||||
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
|
||||
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
|
||||
switch direction {
|
||||
case .previous: focusDirection = .previous
|
||||
case .next: focusDirection = .next
|
||||
case .up: focusDirection = .spatial(.up)
|
||||
case .down: focusDirection = .spatial(.down)
|
||||
case .left: focusDirection = .spatial(.left)
|
||||
case .right: focusDirection = .spatial(.right)
|
||||
}
|
||||
|
||||
// Find the node for the target surface
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// Find the next surface to focus
|
||||
guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the zoomed state for this surface tree.
|
||||
if surfaceTree.zoomed != nil {
|
||||
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
|
||||
}
|
||||
|
||||
// Move focus to the next surface
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: nextSurface, from: target)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) {
|
||||
// The target must be within our tree
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// Toggle the zoomed state
|
||||
if surfaceTree.zoomed == targetNode {
|
||||
// Already zoomed, unzoom it
|
||||
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
|
||||
} else {
|
||||
// We require that the split tree have splits
|
||||
guard surfaceTree.isSplit else { return }
|
||||
|
||||
// Not zoomed or different node zoomed, zoom this node
|
||||
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode)
|
||||
}
|
||||
|
||||
// Move focus to our window. Importantly this ensures that if we click the
|
||||
// reset zoom button in a tab bar of an unfocused tab that we become focused.
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
|
||||
// Ensure focus stays on the target surface. We lose focus when we do
|
||||
// this so we need to grab it again.
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: target)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidResizeSplit(_ notification: Notification) {
|
||||
// The target must be within our tree
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// Extract direction and amount from notification
|
||||
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
|
||||
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
|
||||
|
||||
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
|
||||
guard let amount = amountAny as? UInt16 else { return }
|
||||
|
||||
// Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
|
||||
let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
|
||||
switch direction {
|
||||
case .up: spatialDirection = .up
|
||||
case .down: spatialDirection = .down
|
||||
case .left: spatialDirection = .left
|
||||
case .right: spatialDirection = .right
|
||||
}
|
||||
|
||||
// Use viewBounds for the spatial calculation bounds
|
||||
let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
|
||||
|
||||
// Perform the resize using the new SplitTree resize method
|
||||
do {
|
||||
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to resize split: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
||||
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
||||
|
|
@ -263,20 +651,17 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
|
||||
private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? {
|
||||
// Go through all our surfaces and notify it that the flags changed.
|
||||
if let surfaceTree {
|
||||
var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface }
|
||||
var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 }
|
||||
|
||||
// If we're the main window receiving key input, then we want to avoid
|
||||
// calling this on our focused surface because that'll trigger a double
|
||||
// flagsChanged call.
|
||||
if NSApp.mainWindow == window {
|
||||
surfaces = surfaces.filter { $0 != focusedSurface }
|
||||
}
|
||||
|
||||
for surface in surfaces {
|
||||
surface.flagsChanged(with: event)
|
||||
}
|
||||
// If we're the main window receiving key input, then we want to avoid
|
||||
// calling this on our focused surface because that'll trigger a double
|
||||
// flagsChanged call.
|
||||
if NSApp.mainWindow == window {
|
||||
surfaces = surfaces.filter { $0 != focusedSurface }
|
||||
}
|
||||
|
||||
for surface in surfaces {
|
||||
surface.flagsChanged(with: event)
|
||||
}
|
||||
|
||||
return event
|
||||
|
|
@ -284,11 +669,6 @@ class BaseTerminalController: NSWindowController,
|
|||
|
||||
// MARK: TerminalViewDelegate
|
||||
|
||||
// Note: this is different from surfaceDidTreeChange(from:,to:) because this is called
|
||||
// when the currently set value changed in place and the from:to: variant is called
|
||||
// when the variable was set.
|
||||
func surfaceTreeDidChange() {}
|
||||
|
||||
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
|
||||
let lastFocusedSurface = focusedSurface
|
||||
focusedSurface = to
|
||||
|
|
@ -301,7 +681,7 @@ class BaseTerminalController: NSWindowController,
|
|||
// want to care if the surface is in the tree so we don't listen to titles of
|
||||
// closed surfaces.
|
||||
if let titleSurface = focusedSurface ?? lastFocusedSurface,
|
||||
surfaceTree?.contains(view: titleSurface) ?? false {
|
||||
surfaceTree.contains(titleSurface) {
|
||||
// If we have a surface, we want to listen for title changes.
|
||||
titleSurface.$title
|
||||
.sink { [weak self] in self?.titleDidChange(to: $0) }
|
||||
|
|
@ -336,7 +716,15 @@ class BaseTerminalController: NSWindowController,
|
|||
self.window?.contentResizeIncrements = to
|
||||
}
|
||||
|
||||
func zoomStateDidChange(to: Bool) {}
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resize(to: newRatio)
|
||||
do {
|
||||
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
|
||||
guard let surface = surfaceView.surface else { return }
|
||||
|
|
@ -396,6 +784,8 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
}
|
||||
|
||||
func fullscreenDidChange() {}
|
||||
|
||||
// MARK: Clipboard Confirmation
|
||||
|
||||
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
|
||||
|
|
@ -462,6 +852,11 @@ class BaseTerminalController: NSWindowController,
|
|||
// MARK: NSWindowController
|
||||
|
||||
override func windowDidLoad() {
|
||||
super.windowDidLoad()
|
||||
|
||||
// Setup our undo manager.
|
||||
|
||||
// Everything beyond here is setting up the window
|
||||
guard let window else { return }
|
||||
|
||||
// If there is a hardcoded title in the configuration, we set that
|
||||
|
|
@ -491,35 +886,21 @@ class BaseTerminalController: NSWindowController,
|
|||
guard let window = self.window else { return true }
|
||||
|
||||
// If we have no surfaces, close.
|
||||
guard let node = self.surfaceTree else { return true }
|
||||
if surfaceTree.isEmpty { return true }
|
||||
|
||||
// If we already have an alert, continue with it
|
||||
guard alert == nil else { return false }
|
||||
|
||||
// If our surfaces don't require confirmation, close.
|
||||
if (!node.needsConfirmQuit()) { return true }
|
||||
if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
|
||||
|
||||
// We require confirmation, so show an alert as long as we aren't already.
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Close Terminal?"
|
||||
alert.informativeText = "The terminal still has a running process. If you close the " +
|
||||
"terminal the process will be killed."
|
||||
alert.addButton(withTitle: "Close the Terminal")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: window, completionHandler: { response in
|
||||
self.alert = nil
|
||||
switch (response) {
|
||||
case .alertFirstButtonReturn:
|
||||
alert.window.orderOut(nil)
|
||||
window.close()
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
self.alert = alert
|
||||
confirmClose(
|
||||
messageText: "Close Terminal?",
|
||||
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
||||
) {
|
||||
window.close()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -531,6 +912,9 @@ class BaseTerminalController: NSWindowController,
|
|||
// the view and the window so we had to nil this out to break it but I think this
|
||||
// may now be resolved. We should verify that no memory leaks and we can remove this.
|
||||
window.contentView = nil
|
||||
|
||||
// Make sure we clean up all our undos
|
||||
window.undoManager?.removeAllActions(withTarget: self)
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
|
|
@ -546,10 +930,9 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
|
||||
func windowDidChangeOcclusionState(_ notification: Notification) {
|
||||
guard let surfaceTree = self.surfaceTree else { return }
|
||||
let visible = self.window?.occlusionState.contains(.visible) ?? false
|
||||
for leaf in surfaceTree {
|
||||
if let surface = leaf.surface.surface {
|
||||
for view in surfaceTree {
|
||||
if let surface = view.surface {
|
||||
ghostty_surface_set_occlusion(surface, visible)
|
||||
}
|
||||
}
|
||||
|
|
@ -563,6 +946,11 @@ class BaseTerminalController: NSWindowController,
|
|||
windowFrameDidChange()
|
||||
}
|
||||
|
||||
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
||||
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
|
||||
return appDelegate.undoManager
|
||||
}
|
||||
|
||||
// MARK: First Responder
|
||||
|
||||
@IBAction func close(_ sender: Any) {
|
||||
|
|
|
|||
|
|
@ -1,372 +0,0 @@
|
|||
import Cocoa
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
import Combine
|
||||
|
||||
/// Manages a set of terminal windows. This is effectively an array of TerminalControllers.
|
||||
/// This abstraction helps manage tabs and multi-window scenarios.
|
||||
class TerminalManager {
|
||||
struct Window {
|
||||
let controller: TerminalController
|
||||
let closePublisher: AnyCancellable
|
||||
}
|
||||
|
||||
let ghostty: Ghostty.App
|
||||
|
||||
/// The currently focused surface of the main window.
|
||||
var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
|
||||
|
||||
/// The set of windows we currently have.
|
||||
var windows: [Window] = []
|
||||
|
||||
// Keep track of the last point that our window was launched at so that new
|
||||
// windows "cascade" over each other and don't just launch directly on top
|
||||
// of each other.
|
||||
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
|
||||
|
||||
/// Returns the main window of the managed window stack. If there is no window
|
||||
/// then an arbitrary window will be chosen.
|
||||
private var mainWindow: Window? {
|
||||
for window in windows {
|
||||
if (window.controller.window?.isMainWindow ?? false) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no main window, just use the last window.
|
||||
return windows.last
|
||||
}
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
init(_ ghostty: Ghostty.App) {
|
||||
self.ghostty = ghostty
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(onNewTab),
|
||||
name: Ghostty.Notification.ghosttyNewTab,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(onNewWindow),
|
||||
name: Ghostty.Notification.ghosttyNewWindow,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
let center = NotificationCenter.default
|
||||
center.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Window Management
|
||||
|
||||
/// Create a new terminal window.
|
||||
func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
|
||||
let c = createWindow(withBaseConfig: base)
|
||||
let window = c.window!
|
||||
|
||||
// If the previous focused window was native fullscreen, the new window also
|
||||
// becomes native fullscreen.
|
||||
if let parent = focusedSurface?.window,
|
||||
parent.styleMask.contains(.fullScreen) {
|
||||
window.toggleFullScreen(nil)
|
||||
} else if derivedConfig.windowFullscreen {
|
||||
switch (derivedConfig.windowFullscreenMode) {
|
||||
case .native:
|
||||
// Native has to be done immediately so that our stylemask contains
|
||||
// fullscreen for the logic later in this method.
|
||||
c.toggleFullscreen(mode: .native)
|
||||
|
||||
case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch:
|
||||
// If we're non-native then we have to do it on a later loop
|
||||
// so that the content view is setup.
|
||||
DispatchQueue.main.async {
|
||||
c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All new_window actions force our app to be active.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
||||
// take effect. Our best theory is there is some next-event-loop-tick logic
|
||||
// that Cocoa is doing that we need to be after.
|
||||
DispatchQueue.main.async {
|
||||
// Only cascade if we aren't fullscreen.
|
||||
if (!window.styleMask.contains(.fullScreen)) {
|
||||
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
||||
}
|
||||
|
||||
c.showWindow(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new tab in the current main window. If there are no windows, a window
|
||||
/// is created.
|
||||
func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
|
||||
// If there is no main window, just create a new window
|
||||
guard let parent = mainWindow?.controller.window else {
|
||||
newWindow(withBaseConfig: base)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new window and add it to the parent
|
||||
newTab(to: parent, withBaseConfig: base)
|
||||
}
|
||||
|
||||
private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) {
|
||||
// Making sure that we're dealing with a TerminalController
|
||||
guard parent.windowController is TerminalController else { return }
|
||||
|
||||
// If our parent is in non-native fullscreen, then new tabs do not work.
|
||||
// See: https://github.com/mitchellh/ghostty/issues/392
|
||||
if let controller = parent.windowController as? TerminalController,
|
||||
let fullscreenStyle = controller.fullscreenStyle,
|
||||
fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Cannot Create New Tab"
|
||||
alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: parent)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new window and add it to the parent
|
||||
let controller = createWindow(withBaseConfig: base)
|
||||
let window = controller.window!
|
||||
|
||||
// If the parent is miniaturized, then macOS exhibits really strange behaviors
|
||||
// so we have to bring it back out.
|
||||
if (parent.isMiniaturized) { parent.deminiaturize(self) }
|
||||
|
||||
// If our parent tab group already has this window, macOS added it and
|
||||
// we need to remove it so we can set the correct order in the next line.
|
||||
// If we don't do this, macOS gets really confused and the tabbedWindows
|
||||
// state becomes incorrect.
|
||||
//
|
||||
// At the time of writing this code, the only known case this happens
|
||||
// is when the "+" button is clicked in the tab bar.
|
||||
if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil {
|
||||
tg.removeWindow(window)
|
||||
}
|
||||
|
||||
// Our windows start out invisible. We need to make it visible. If we
|
||||
// don't do this then various features such as window blur won't work because
|
||||
// the macOS APIs only work on a visible window.
|
||||
controller.showWindow(self)
|
||||
|
||||
// If we have the "hidden" titlebar style we want to create new
|
||||
// tabs as windows instead, so just skip adding it to the parent.
|
||||
if (derivedConfig.macosTitlebarStyle != "hidden") {
|
||||
// Add the window to the tab group and show it.
|
||||
switch derivedConfig.windowNewTabPosition {
|
||||
case "end":
|
||||
// If we already have a tab group and we want the new tab to open at the end,
|
||||
// then we use the last window in the tab group as the parent.
|
||||
if let last = parent.tabGroup?.windows.last {
|
||||
last.addTabbedWindow(window, ordered: .above)
|
||||
} else {
|
||||
fallthrough
|
||||
}
|
||||
case "current": fallthrough
|
||||
default:
|
||||
parent.addTabbedWindow(window, ordered: .above)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(self)
|
||||
|
||||
// It takes an event loop cycle until the macOS tabGroup state becomes
|
||||
// consistent which causes our tab labeling to be off when the "+" button
|
||||
// is used in the tab bar. This fixes that. If we can find a more robust
|
||||
// solution we should do that.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() }
|
||||
}
|
||||
|
||||
/// Creates a window controller, adds it to our managed list, and returns it.
|
||||
func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController {
|
||||
// Initialize our controller to load the window
|
||||
let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree)
|
||||
|
||||
// Create a listener for when the window is closed so we can remove it.
|
||||
let pubClose = NotificationCenter.default.publisher(
|
||||
for: NSWindow.willCloseNotification,
|
||||
object: c.window!
|
||||
).sink { notification in
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
guard let c = window.windowController as? TerminalController else { return }
|
||||
self.removeWindow(c)
|
||||
}
|
||||
|
||||
// Keep track of every window we manage
|
||||
windows.append(Window(
|
||||
controller: c,
|
||||
closePublisher: pubClose
|
||||
))
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func removeWindow(_ controller: TerminalController) {
|
||||
// Remove it from our managed set
|
||||
guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return }
|
||||
let w = self.windows[idx]
|
||||
self.windows.remove(at: idx)
|
||||
|
||||
// Ensure any publishers we have are cancelled
|
||||
w.closePublisher.cancel()
|
||||
|
||||
// If we remove a window, we reset the cascade point to the key window so that
|
||||
// the next window cascade's from that one.
|
||||
if let focusedWindow = NSApplication.shared.keyWindow {
|
||||
// If we are NOT the focused window, then we are a tabbed window. If we
|
||||
// are closing a tabbed window, we want to set the cascade point to be
|
||||
// the next cascade point from this window.
|
||||
if focusedWindow != controller.window {
|
||||
// The cascadeTopLeft call below should NOT move the window. Starting with
|
||||
// macOS 15, we found that specifically when used with the new window snapping
|
||||
// features of macOS 15, this WOULD move the frame. So we keep track of the
|
||||
// old frame and restore it if necessary. Issue:
|
||||
// https://github.com/ghostty-org/ghostty/issues/2565
|
||||
let oldFrame = focusedWindow.frame
|
||||
|
||||
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
|
||||
|
||||
if focusedWindow.frame != oldFrame {
|
||||
focusedWindow.setFrame(oldFrame, display: true)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If we are the focused window, then we set the last cascade point to
|
||||
// our own frame so that it shows up in the same spot.
|
||||
let frame = focusedWindow.frame
|
||||
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
|
||||
}
|
||||
|
||||
// I don't think we strictly have to do this but if a window is
|
||||
// closed I want to make sure that the app state is invalided so
|
||||
// we don't reopen closed windows.
|
||||
NSApplication.shared.invalidateRestorableState()
|
||||
}
|
||||
|
||||
/// Close all windows, asking for confirmation if necessary.
|
||||
func closeAllWindows() {
|
||||
var needsConfirm: Bool = false
|
||||
for w in self.windows {
|
||||
if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) {
|
||||
needsConfirm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsConfirm) {
|
||||
for w in self.windows {
|
||||
w.controller.close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If we don't have a main window, we just close all windows because
|
||||
// we have no window to show the modal on top of. I'm sure there's a way
|
||||
// to do an app-level alert but I don't know how and this case should never
|
||||
// really happen.
|
||||
guard let alertWindow = mainWindow?.controller.window else {
|
||||
for w in self.windows {
|
||||
w.controller.close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If we need confirmation by any, show one confirmation for all windows
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Close All Windows?"
|
||||
alert.informativeText = "All terminal sessions will be terminated."
|
||||
alert.addButton(withTitle: "Close All Windows")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: alertWindow, completionHandler: { response in
|
||||
if (response == .alertFirstButtonReturn) {
|
||||
for w in self.windows {
|
||||
w.controller.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Relabels all the tabs with the proper keyboard shortcut.
|
||||
func relabelAllTabs() {
|
||||
for w in windows {
|
||||
w.controller.relabelTabs()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@objc private func onNewWindow(notification: SwiftUI.Notification) {
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
self.newWindow(withBaseConfig: config)
|
||||
}
|
||||
|
||||
@objc private func onNewTab(notification: SwiftUI.Notification) {
|
||||
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let window = surfaceView.window else { return }
|
||||
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? Ghostty.SurfaceConfiguration
|
||||
|
||||
self.newTab(to: window, withBaseConfig: config)
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
// We only care if the configuration is a global configuration, not a
|
||||
// surface-specific one.
|
||||
guard notification.object == nil else { return }
|
||||
|
||||
// Get our managed configuration object out
|
||||
guard let config = notification.userInfo?[
|
||||
Notification.Name.GhosttyConfigChangeKey
|
||||
] as? Ghostty.Config else { return }
|
||||
|
||||
// Update our derived config
|
||||
self.derivedConfig = DerivedConfig(config)
|
||||
}
|
||||
|
||||
private struct DerivedConfig {
|
||||
let windowFullscreen: Bool
|
||||
let windowFullscreenMode: FullscreenMode
|
||||
let macosTitlebarStyle: String
|
||||
let windowNewTabPosition: String
|
||||
|
||||
init() {
|
||||
self.windowFullscreen = false
|
||||
self.windowFullscreenMode = .native
|
||||
self.macosTitlebarStyle = "transparent"
|
||||
self.windowNewTabPosition = ""
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.windowFullscreen = config.windowFullscreen
|
||||
self.windowFullscreenMode = config.windowFullscreenMode
|
||||
self.macosTitlebarStyle = config.macosTitlebarStyle
|
||||
self.windowNewTabPosition = config.windowNewTabPosition
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import Cocoa
|
|||
class TerminalRestorableState: Codable {
|
||||
static let selfKey = "state"
|
||||
static let versionKey = "version"
|
||||
static let version: Int = 2
|
||||
static let version: Int = 3
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: Ghostty.SplitNode?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
|
||||
init(from controller: TerminalController) {
|
||||
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
|
||||
|
|
@ -83,18 +83,29 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
|||
// can be found for events from libghostty. This uses the low-level
|
||||
// createWindow so that AppKit can place the window wherever it should
|
||||
// be.
|
||||
let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree)
|
||||
let c = TerminalController.init(
|
||||
appDelegate.ghostty,
|
||||
withSurfaceTree: state.surfaceTree)
|
||||
guard let window = c.window else {
|
||||
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
|
||||
return
|
||||
}
|
||||
|
||||
// Setup our restored state on the controller
|
||||
if let focusedStr = state.focusedSurface,
|
||||
let focusedUUID = UUID(uuidString: focusedStr),
|
||||
let view = c.surfaceTree?.findUUID(uuid: focusedUUID) {
|
||||
c.focusedSurface = view
|
||||
restoreFocus(to: view, inWindow: window)
|
||||
// Find the focused surface in surfaceTree
|
||||
if let focusedStr = state.focusedSurface {
|
||||
var foundView: Ghostty.SurfaceView?
|
||||
for view in c.surfaceTree {
|
||||
if view.uuid.uuidString == focusedStr {
|
||||
foundView = view
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let view = foundView {
|
||||
c.focusedSurface = view
|
||||
restoreFocus(to: view, inWindow: window)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(window, nil)
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import Cocoa
|
||||
|
||||
// Custom NSToolbar subclass that displays a centered window title,
|
||||
// in order to accommodate the titlebar tabs feature.
|
||||
class TerminalToolbar: NSToolbar, NSToolbarDelegate {
|
||||
private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
|
||||
|
||||
var titleText: String {
|
||||
get {
|
||||
titleTextField.stringValue
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.stringValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var titleFont: NSFont? {
|
||||
get {
|
||||
titleTextField.font
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.font = newValue
|
||||
}
|
||||
}
|
||||
|
||||
override init(identifier: NSToolbar.Identifier) {
|
||||
super.init(identifier: identifier)
|
||||
|
||||
delegate = self
|
||||
centeredItemIdentifiers.insert(.titleText)
|
||||
}
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar,
|
||||
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
|
||||
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
var item: NSToolbarItem
|
||||
|
||||
switch itemIdentifier {
|
||||
case .titleText:
|
||||
item = NSToolbarItem(itemIdentifier: .titleText)
|
||||
item.view = self.titleTextField
|
||||
item.visibilityPriority = .user
|
||||
|
||||
// This ensures the title text field doesn't disappear when shrinking the view
|
||||
self.titleTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
// Add constraints to the toolbar item's view
|
||||
NSLayoutConstraint.activate([
|
||||
// Set the height constraint to match the toolbar's height
|
||||
self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed
|
||||
])
|
||||
|
||||
item.isEnabled = true
|
||||
case .resetZoom:
|
||||
item = NSToolbarItem(itemIdentifier: .resetZoom)
|
||||
default:
|
||||
item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.titleText, .flexibleSpace, .space, .resetZoom]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
// These space items are here to ensure that the title remains centered when it starts
|
||||
// getting smaller than the max size so starts clipping. Lucky for us, two of the
|
||||
// built-in spacers plus the un-zoom button item seems to exactly match the space
|
||||
// on the left that's reserved for the window buttons.
|
||||
return [.flexibleSpace, .titleText, .flexibleSpace]
|
||||
}
|
||||
}
|
||||
|
||||
/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window.
|
||||
fileprivate class CenteredDynamicLabel: NSTextField {
|
||||
override func viewDidMoveToSuperview() {
|
||||
// Configure the text field
|
||||
isEditable = false
|
||||
isBordered = false
|
||||
drawsBackground = false
|
||||
alignment = .center
|
||||
lineBreakMode = .byTruncatingTail
|
||||
cell?.truncatesLastVisibleLine = true
|
||||
|
||||
// Use Auto Layout
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Set content hugging and compression resistance priorities
|
||||
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
}
|
||||
|
||||
// Vertically center the text
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else {
|
||||
super.draw(dirtyRect)
|
||||
return
|
||||
}
|
||||
|
||||
let textSize = attributedString.size()
|
||||
|
||||
let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better
|
||||
|
||||
let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset,
|
||||
width: self.bounds.width, height: textSize.height)
|
||||
|
||||
attributedString.draw(in: centeredRect)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSToolbarItem.Identifier {
|
||||
static let resetZoom = NSToolbarItem.Identifier("ResetZoom")
|
||||
static let titleText = NSToolbarItem.Identifier("TitleText")
|
||||
}
|
||||
|
|
@ -14,15 +14,11 @@ protocol TerminalViewDelegate: AnyObject {
|
|||
/// The cell size changed.
|
||||
func cellSizeDidChange(to: NSSize)
|
||||
|
||||
/// The surface tree did change in some way, i.e. a split was added, removed, etc. This is
|
||||
/// not called initially.
|
||||
func surfaceTreeDidChange()
|
||||
|
||||
/// This is called when a split is zoomed.
|
||||
func zoomStateDidChange(to: Bool)
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// The view model is a required implementation for TerminalView callers. This contains
|
||||
|
|
@ -31,7 +27,7 @@ protocol TerminalViewDelegate: AnyObject {
|
|||
protocol TerminalViewModel: ObservableObject {
|
||||
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
|
||||
/// and children. This should be @Published.
|
||||
var surfaceTree: Ghostty.SplitNode? { get set }
|
||||
var surfaceTree: SplitTree<Ghostty.SurfaceView> { get set }
|
||||
|
||||
/// The command palette state.
|
||||
var commandPaletteIsShowing: Bool { get set }
|
||||
|
|
@ -57,7 +53,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||
// Various state values sent back up from the currently focused terminals.
|
||||
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
|
||||
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
|
||||
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
|
||||
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
|
||||
|
||||
// The pwd of the focused surface as a URL
|
||||
|
|
@ -81,7 +76,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||
DebugBuildWarningView()
|
||||
}
|
||||
|
||||
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
|
||||
TerminalSplitTreeView(
|
||||
tree: viewModel.surfaceTree,
|
||||
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
|
||||
.environmentObject(ghostty)
|
||||
.focused($focused)
|
||||
.onAppear { self.focused = true }
|
||||
|
|
@ -100,15 +97,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||
guard let size = newValue else { return }
|
||||
self.delegate?.cellSizeDidChange(to: size)
|
||||
}
|
||||
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
|
||||
// This is funky, but its the best way I could think of to detect
|
||||
// ANY CHANGE within the deeply nested surface tree -- detecting a change
|
||||
// in the hash value.
|
||||
self.delegate?.surfaceTreeDidChange()
|
||||
}
|
||||
.onChange(of: zoomedSplit) { newValue in
|
||||
self.delegate?.zoomStateDidChange(to: newValue ?? false)
|
||||
}
|
||||
}
|
||||
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
|
||||
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
|
||||
|
|
@ -151,6 +139,10 @@ struct DebugBuildWarningView: View {
|
|||
}
|
||||
.background(Color(.windowBackgroundColor))
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Debug build warning")
|
||||
.accessibilityValue("Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development.")
|
||||
.accessibilityAddTraits(.isStaticText)
|
||||
.onTapGesture {
|
||||
isPopover = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import AppKit
|
||||
|
||||
class HiddenTitlebarTerminalWindow: TerminalWindow {
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
// Setup our initial style
|
||||
reapplyHiddenStyle()
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(fullscreenDidExit(_:)),
|
||||
name: .fullscreenDidExit,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
/// Apply the hidden titlebar style.
|
||||
private func reapplyHiddenStyle() {
|
||||
styleMask = [
|
||||
// We need `titled` in the mask to get the normal window frame
|
||||
.titled,
|
||||
|
||||
// Full size content view so we can extend
|
||||
// content in to the hidden titlebar's area
|
||||
.fullSizeContentView,
|
||||
|
||||
.resizable,
|
||||
.closable,
|
||||
.miniaturizable,
|
||||
]
|
||||
|
||||
// Hide the title
|
||||
titleVisibility = .hidden
|
||||
titlebarAppearsTransparent = true
|
||||
|
||||
// Hide the traffic lights (window control buttons)
|
||||
standardWindowButton(.closeButton)?.isHidden = true
|
||||
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
standardWindowButton(.zoomButton)?.isHidden = true
|
||||
|
||||
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
|
||||
tabbingMode = .disallowed
|
||||
|
||||
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
|
||||
// some operations that appear to bring back the titlebar visibility so this ensures
|
||||
// it is gone forever.
|
||||
if let themeFrame = contentView?.superview,
|
||||
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
|
||||
titleBarContainer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSWindow
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
// Updating the title text as above automatically reveals the
|
||||
// native title view in macOS 15.0 and above. Since we're using
|
||||
// a custom view instead, we need to re-hide it.
|
||||
reapplyHiddenStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// We override this so that with the hidden titlebar style the titlebar
|
||||
// area is not draggable.
|
||||
override var contentLayoutRect: CGRect {
|
||||
var rect = super.contentLayoutRect
|
||||
rect.origin.y = 0
|
||||
rect.size.height = self.frame.height
|
||||
return rect
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc private func fullscreenDidExit(_ notification: Notification) {
|
||||
// Make sure they're talking about our window
|
||||
guard let fullscreen = notification.object as? FullscreenBase else { return }
|
||||
guard fullscreen.window == self else { return }
|
||||
|
||||
// On exit we need to reapply the style because macOS breaks it usually.
|
||||
// This is safe to call repeatedly so if its not broken its still safe.
|
||||
reapplyHiddenStyle()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
|
|
@ -17,10 +17,10 @@
|
|||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="HiddenTitlebarTerminalWindow" customModule="Ghostty" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="132" y="-82"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TitlebarTabsTahoeTerminalWindow" customModule="Ghostty" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="132" y="-82"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TitlebarTabsVenturaTerminalWindow" customModule="Ghostty" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="132" y="-82"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TransparentTitlebarTerminalWindow" customModule="Ghostty" customModuleProvider="target">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="132" y="-82"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
/// The base class for all standalone, "normal" terminal windows. This sets the basic
|
||||
/// style and configuration of the window based on the app configuration.
|
||||
class TerminalWindow: NSWindow {
|
||||
/// 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"
|
||||
|
||||
/// The view model for SwiftUI views
|
||||
private var viewModel = ViewModel()
|
||||
|
||||
/// Reset split zoom button in titlebar
|
||||
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig = .init()
|
||||
|
||||
/// Gets the terminal controller from the window controller.
|
||||
var terminalController: TerminalController? {
|
||||
windowController as? TerminalController
|
||||
}
|
||||
|
||||
// MARK: NSWindow Overrides
|
||||
|
||||
override var toolbar: NSToolbar? {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
// When we have a toolbar, our SwiftUI view needs to know for layout
|
||||
self.viewModel.hasToolbar = self.toolbar != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
|
||||
|
||||
// All new windows are based on the app config at the time of creation.
|
||||
let config = appDelegate.ghostty.config
|
||||
|
||||
// Setup our initial config
|
||||
derivedConfig = .init(config)
|
||||
|
||||
// If window decorations are disabled, remove our title
|
||||
if (!config.windowDecorations) { styleMask.remove(.titled) }
|
||||
|
||||
// Set our window positioning to coordinates if config value exists, otherwise
|
||||
// fallback to original centering behavior
|
||||
setInitialWindowPosition(
|
||||
x: config.windowPositionX,
|
||||
y: config.windowPositionY,
|
||||
windowDecorations: config.windowDecorations)
|
||||
|
||||
// If our traffic buttons should be hidden, then hide them
|
||||
if config.macosWindowButtons == .hidden {
|
||||
hideWindowButtons()
|
||||
}
|
||||
|
||||
// Create our reset zoom titlebar accessory. We have to have a title
|
||||
// to do this or AppKit triggers an assertion.
|
||||
if styleMask.contains(.titled) {
|
||||
resetZoomAccessory.layoutAttribute = .right
|
||||
resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView(
|
||||
viewModel: viewModel,
|
||||
action: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.terminalController?.splitZoom(self)
|
||||
}))
|
||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
// 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])
|
||||
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
stackView.spacing = 3
|
||||
tab.accessoryView = stackView
|
||||
|
||||
// Get our saved level
|
||||
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
|
||||
}
|
||||
|
||||
// Both of these must be true for windows without decorations to be able to
|
||||
// still become key/main and receive events.
|
||||
override var canBecomeKey: Bool { return true }
|
||||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
override func becomeKey() {
|
||||
super.becomeKey()
|
||||
resetZoomTabButton.contentTintColor = .controlAccentColor
|
||||
}
|
||||
|
||||
override func resignKey() {
|
||||
super.resignKey()
|
||||
resetZoomTabButton.contentTintColor = .secondaryLabelColor
|
||||
}
|
||||
|
||||
override func becomeMain() {
|
||||
super.becomeMain()
|
||||
|
||||
// Its possible we miss the accessory titlebar call so we check again
|
||||
// whenever the window becomes main. Both of these are idempotent.
|
||||
if hasTabBar {
|
||||
tabBarDidAppear()
|
||||
} else {
|
||||
tabBarDidDisappear()
|
||||
}
|
||||
}
|
||||
|
||||
override func mergeAllWindows(_ sender: Any?) {
|
||||
super.mergeAllWindows(sender)
|
||||
|
||||
// It takes an event loop cycle to merge all the windows so we set a
|
||||
// short timer to relabel the tabs (issue #1902)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.terminalController?.relabelTabs()
|
||||
}
|
||||
}
|
||||
|
||||
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
||||
super.addTitlebarAccessoryViewController(childViewController)
|
||||
|
||||
// Tab bar is attached as a titlebar accessory view controller (layout bottom). We
|
||||
// can detect when it is shown or hidden by overriding add/remove and searching for
|
||||
// it. This has been verified to work on macOS 12 to 26
|
||||
if isTabBar(childViewController) {
|
||||
childViewController.identifier = Self.tabBarIdentifier
|
||||
tabBarDidAppear()
|
||||
}
|
||||
}
|
||||
|
||||
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||
if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) {
|
||||
tabBarDidDisappear()
|
||||
}
|
||||
|
||||
super.removeTitlebarAccessoryViewController(at: index)
|
||||
}
|
||||
|
||||
// MARK: Tab Bar
|
||||
|
||||
/// This identifier is attached to the tab bar view controller when we detect it being
|
||||
/// added.
|
||||
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||
|
||||
/// Returns true if there is a tab bar visible on this window.
|
||||
var hasTabBar: Bool {
|
||||
contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil
|
||||
}
|
||||
|
||||
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
|
||||
if childViewController.identifier == nil {
|
||||
// The good case
|
||||
if childViewController.view.contains(className: "NSTabBar") {
|
||||
return true
|
||||
}
|
||||
|
||||
// When a new window is attached to an existing tab group, AppKit adds
|
||||
// an empty NSView as an accessory view and adds the tab bar later. If
|
||||
// we're at the bottom and are a single NSView we assume its a tab bar.
|
||||
if childViewController.layoutAttribute == .bottom &&
|
||||
childViewController.view.className == "NSView" &&
|
||||
childViewController.view.subviews.isEmpty {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// View controllers should be tagged with this as soon as possible to
|
||||
// increase our accuracy. We do this manually.
|
||||
return childViewController.identifier == Self.tabBarIdentifier
|
||||
}
|
||||
|
||||
private func tabBarDidAppear() {
|
||||
// Remove our reset zoom accessory. For some reason having a SwiftUI
|
||||
// titlebar accessory causes our content view scaling to be wrong.
|
||||
// Removing it fixes it, we just need to remember to add it again later.
|
||||
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
||||
removeTitlebarAccessoryViewController(at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
private func tabBarDidDisappear() {
|
||||
if styleMask.contains(.titled) {
|
||||
if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil {
|
||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Tab Key Equivalents
|
||||
|
||||
var keyEquivalent: String? = nil {
|
||||
didSet {
|
||||
// When our key equivalent is set, we must update the tab label.
|
||||
guard let keyEquivalent else {
|
||||
keyEquivalentLabel.attributedStringValue = NSAttributedString()
|
||||
return
|
||||
}
|
||||
|
||||
keyEquivalentLabel.attributedStringValue = NSAttributedString(
|
||||
string: "\(keyEquivalent) ",
|
||||
attributes: [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// The label that has the key equivalent for tab views.
|
||||
private lazy var keyEquivalentLabel: NSTextField = {
|
||||
let label = NSTextField(labelWithAttributedString: NSAttributedString())
|
||||
label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
|
||||
label.postsFrameChangedNotifications = true
|
||||
return label
|
||||
}()
|
||||
|
||||
// MARK: Surface Zoom
|
||||
|
||||
/// Set to true if a surface is currently zoomed to show the reset zoom button.
|
||||
var surfaceIsZoomed: Bool = false {
|
||||
didSet {
|
||||
// Show/hide our reset zoom button depending on if we're zoomed.
|
||||
// We want to show it if we are zoomed.
|
||||
resetZoomTabButton.isHidden = !surfaceIsZoomed
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var resetZoomTabButton: NSButton = generateResetZoomButton()
|
||||
|
||||
private func generateResetZoomButton() -> NSButton {
|
||||
let button = NSButton()
|
||||
button.isHidden = true
|
||||
button.target = terminalController
|
||||
button.action = #selector(TerminalController.splitZoom(_:))
|
||||
button.isBordered = false
|
||||
button.allowsExpansionToolTips = true
|
||||
button.toolTip = "Reset Zoom"
|
||||
button.contentTintColor = .controlAccentColor
|
||||
button.state = .on
|
||||
button.image = NSImage(named:"ResetZoom")
|
||||
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
return button
|
||||
}
|
||||
|
||||
// MARK: Title Text
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
// Whenever we change the window title we must also update our
|
||||
// tab title if we're using custom fonts.
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
}
|
||||
|
||||
// Used to set the titlebar font.
|
||||
var titlebarFont: NSFont? {
|
||||
didSet {
|
||||
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
titlebarTextField?.font = font
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
}
|
||||
|
||||
// Find the NSTextField responsible for displaying the titlebar's title.
|
||||
private var titlebarTextField: NSTextField? {
|
||||
titlebarContainer?
|
||||
.firstDescendant(withClassName: "NSTitlebarView")?
|
||||
.firstDescendant(withClassName: "NSTextField") as? NSTextField
|
||||
}
|
||||
|
||||
// Return a styled representation of our title property.
|
||||
var attributedTitle: NSAttributedString? {
|
||||
guard let titlebarFont = titlebarFont else { return nil }
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: titlebarFont,
|
||||
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
||||
]
|
||||
return NSAttributedString(string: title, attributes: attributes)
|
||||
}
|
||||
|
||||
var titlebarContainer: NSView? {
|
||||
// If we aren't fullscreen then the titlebar container is part of our window.
|
||||
if !styleMask.contains(.fullScreen) {
|
||||
return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
|
||||
}
|
||||
|
||||
// If we are fullscreen, the titlebar container view is part of a separate
|
||||
// "fullscreen window", we need to find the window and then get the view.
|
||||
for window in NSApplication.shared.windows {
|
||||
// This is the private window class that contains the toolbar
|
||||
guard window.className == "NSToolbarFullScreenWindow" else { continue }
|
||||
|
||||
// The parent will match our window. This is used to filter the correct
|
||||
// fullscreen window if we have multiple.
|
||||
guard window.parent == self else { continue }
|
||||
|
||||
return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Positioning And Styling
|
||||
|
||||
/// This is called by the controller when there is a need to reset the window appearance.
|
||||
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
// If our window is not visible, then we do nothing. Some things such as blurring
|
||||
// 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 }
|
||||
|
||||
// Basic properties
|
||||
appearance = surfaceConfig.windowAppearance
|
||||
hasShadow = surfaceConfig.macosWindowShadow
|
||||
|
||||
// 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.
|
||||
if !styleMask.contains(.fullScreen) &&
|
||||
surfaceConfig.backgroundOpacity < 1
|
||||
{
|
||||
isOpaque = false
|
||||
|
||||
// This is weird, but we don't use ".clear" because this creates a look that
|
||||
// matches Terminal.app much more closer. This lets users transition from
|
||||
// Terminal.app more easily.
|
||||
backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
ghostty_set_window_background_blur(
|
||||
appDelegate.ghostty.app,
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
} else {
|
||||
isOpaque = true
|
||||
|
||||
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
|
||||
self.backgroundColor = backgroundColor.withAlphaComponent(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferred window background color. The current window background color may not be set
|
||||
/// to this, since this is dynamic based on the state of the surface tree.
|
||||
///
|
||||
/// This background color will include alpha transparency if set. If the caller doesn't want that,
|
||||
/// change the alpha channel again manually.
|
||||
var preferredBackgroundColor: NSColor? {
|
||||
if let terminalController, !terminalController.surfaceTree.isEmpty {
|
||||
let surface: Ghostty.SurfaceView?
|
||||
|
||||
// If our focused surface borders the top then we prefer its background color
|
||||
if let focusedSurface = terminalController.focusedSurface,
|
||||
let treeRoot = terminalController.surfaceTree.root,
|
||||
let focusedNode = treeRoot.node(view: focusedSurface),
|
||||
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
|
||||
surface = focusedSurface
|
||||
} else {
|
||||
// If it doesn't border the top, we use the top-left leaf
|
||||
surface = terminalController.surfaceTree.root?.leftmostLeaf()
|
||||
}
|
||||
|
||||
if let surface {
|
||||
let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor
|
||||
let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
|
||||
return NSColor(backgroundColor).withAlphaComponent(alpha)
|
||||
}
|
||||
}
|
||||
|
||||
let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
|
||||
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
|
||||
}
|
||||
|
||||
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
|
||||
// If we don't have an X/Y then we try to use the previously saved window pos.
|
||||
guard let x, let y else {
|
||||
if (!LastWindowPosition.shared.restore(self)) {
|
||||
center()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the screen our window is being placed on otherwise our primary screen.
|
||||
guard let screen = screen ?? NSScreen.screens.first else {
|
||||
center()
|
||||
return
|
||||
}
|
||||
|
||||
// Orient based on the top left of the primary monitor
|
||||
let frame = screen.visibleFrame
|
||||
setFrameOrigin(.init(
|
||||
x: frame.minX + CGFloat(x),
|
||||
y: frame.maxY - (CGFloat(y) + frame.height)))
|
||||
}
|
||||
|
||||
private func hideWindowButtons() {
|
||||
standardWindowButton(.closeButton)?.isHidden = true
|
||||
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
standardWindowButton(.zoomButton)?.isHidden = true
|
||||
}
|
||||
|
||||
// MARK: Config
|
||||
|
||||
struct DerivedConfig {
|
||||
let backgroundColor: NSColor
|
||||
let backgroundOpacity: Double
|
||||
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
||||
|
||||
init() {
|
||||
self.backgroundColor = NSColor.windowBackgroundColor
|
||||
self.backgroundOpacity = 1
|
||||
self.macosWindowButtons = .visible
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.backgroundColor = NSColor(config.backgroundColor)
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.macosWindowButtons = config.macosWindowButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SwiftUI View
|
||||
|
||||
extension TerminalWindow {
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var isSurfaceZoomed: Bool = false
|
||||
@Published var hasToolbar: Bool = false
|
||||
}
|
||||
|
||||
struct ResetZoomAccessoryView: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
let action: () -> Void
|
||||
|
||||
// The padding from the top that the view appears. This was all just manually
|
||||
// measured based on the OS.
|
||||
var topPadding: CGFloat {
|
||||
if #available(macOS 26.0, *) {
|
||||
return viewModel.hasToolbar ? 10 : 5
|
||||
} else {
|
||||
return viewModel.hasToolbar ? 9 : 4
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isSurfaceZoomed {
|
||||
VStack {
|
||||
Button(action: action) {
|
||||
Image("ResetZoom")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Reset Split Zoom")
|
||||
.frame(width: 20, height: 20)
|
||||
Spacer()
|
||||
}
|
||||
// With a toolbar, the window title is taller, so we need more padding
|
||||
// to properly align.
|
||||
.padding(.top, topPadding)
|
||||
// We always need space at the end of the titlebar
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later.
|
||||
///
|
||||
/// This inherits from transparent styling so that the titlebar matches the background color
|
||||
/// of the window.
|
||||
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
|
||||
/// The view model for SwiftUI views
|
||||
private var viewModel = ViewModel()
|
||||
|
||||
deinit {
|
||||
tabBarObserver = nil
|
||||
}
|
||||
|
||||
// MARK: NSWindow
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
viewModel.title = title
|
||||
}
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
// We must hide the title since we're going to be moving tabs into
|
||||
// the titlebar which have their own title.
|
||||
titleVisibility = .hidden
|
||||
|
||||
// Create a toolbar
|
||||
let toolbar = NSToolbar(identifier: "TerminalToolbar")
|
||||
toolbar.delegate = self
|
||||
toolbar.centeredItemIdentifiers.insert(.title)
|
||||
self.toolbar = toolbar
|
||||
toolbarStyle = .unifiedCompact
|
||||
}
|
||||
|
||||
override func becomeMain() {
|
||||
super.becomeMain()
|
||||
|
||||
// Check if we have a tab bar and set it up if we have to. See the comment
|
||||
// on this function to learn why we need to check this here.
|
||||
setupTabBar()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// If this is the tab bar then we need to set it up for the titlebar
|
||||
guard isTabBar(childViewController) else {
|
||||
super.addTitlebarAccessoryViewController(childViewController)
|
||||
return
|
||||
}
|
||||
|
||||
// Some setup needs to happen BEFORE it is added, such as layout. If
|
||||
// we don't do this before the call below, we'll trigger an AppKit
|
||||
// assertion.
|
||||
childViewController.layoutAttribute = .right
|
||||
|
||||
super.addTitlebarAccessoryViewController(childViewController)
|
||||
|
||||
// Setup the tab bar to go into the titlebar.
|
||||
DispatchQueue.main.async {
|
||||
// HACK: wait a tick before doing anything, to avoid edge cases during startup... :/
|
||||
// If we don't do this then on launch windows with restored state with tabs will end
|
||||
// up with messed up tab bars that don't show all tabs.
|
||||
self.setupTabBar()
|
||||
}
|
||||
}
|
||||
|
||||
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||
guard let childViewController = titlebarAccessoryViewControllers[safe: index],
|
||||
isTabBar(childViewController) else {
|
||||
super.removeTitlebarAccessoryViewController(at: index)
|
||||
return
|
||||
}
|
||||
|
||||
super.removeTitlebarAccessoryViewController(at: index)
|
||||
|
||||
removeTabBar()
|
||||
}
|
||||
|
||||
// MARK: Tab Bar Setup
|
||||
|
||||
private var tabBarObserver: NSObjectProtocol? {
|
||||
didSet {
|
||||
// When we change this we want to clear our old observer
|
||||
guard let oldValue else { return }
|
||||
NotificationCenter.default.removeObserver(oldValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the NSTabBar that is on the window and convert it into titlebar tabs.
|
||||
///
|
||||
/// Let me explain more background on what is happening here. When a tab bar is created, only the
|
||||
/// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit
|
||||
/// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar
|
||||
/// is removed from the view hierarchy.
|
||||
///
|
||||
/// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit
|
||||
/// creates an accessory view controller for every window in the tab group, but only attaches
|
||||
/// the actual NSTabBar to the main window's accessory view.
|
||||
///
|
||||
/// The best way I've found to detect this is to search for and setup the tab bar anytime the
|
||||
/// window gains focus. There are probably edge cases to check but to resolve all this I made
|
||||
/// this function which is idempotent to call.
|
||||
///
|
||||
/// There are more scenarios to look out for and they're documented within the method.
|
||||
func setupTabBar() {
|
||||
// We only want to setup the observer once
|
||||
guard tabBarObserver == nil else { return }
|
||||
|
||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||
guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return }
|
||||
|
||||
// View model updates must happen on their own ticks.
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.hasTabBar = true
|
||||
}
|
||||
|
||||
// Find our clip view
|
||||
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
||||
guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return }
|
||||
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||
|
||||
// The container is the view that we'll constrain our tab bar within.
|
||||
let container = toolbarView
|
||||
|
||||
// The padding for the tab bar. If we're showing window buttons then
|
||||
// we need to offset the window buttons.
|
||||
let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) {
|
||||
case .hidden: 0
|
||||
case .visible: 70
|
||||
}
|
||||
|
||||
// Constrain the accessory clip view (the parent of the accessory view
|
||||
// usually that clips the children) to the container view.
|
||||
clipView.translatesAutoresizingMaskIntoConstraints = false
|
||||
accessoryView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Setup all our constraints
|
||||
NSLayoutConstraint.activate([
|
||||
clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding),
|
||||
clipView.rightAnchor.constraint(equalTo: container.rightAnchor),
|
||||
clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
|
||||
clipView.heightAnchor.constraint(equalTo: container.heightAnchor),
|
||||
accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor),
|
||||
accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor),
|
||||
accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor),
|
||||
accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor),
|
||||
])
|
||||
|
||||
clipView.needsLayout = true
|
||||
accessoryView.needsLayout = true
|
||||
|
||||
// Setup an observer for the NSTabBar frame. When system appearance changes or
|
||||
// other events occur, the tab bar can temporarily become zero-sized. 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
|
||||
tabBarObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: tabBar,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
|
||||
// Check if either width or height is zero
|
||||
guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return }
|
||||
|
||||
// Remove the observer so we can call setup again.
|
||||
self.tabBarObserver = nil
|
||||
|
||||
// Wait a tick to let the new tab bars appear and then set them up.
|
||||
DispatchQueue.main.async {
|
||||
self.setupTabBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTabBar() {
|
||||
// View model needs to be updated on another tick because it
|
||||
// triggers view updates.
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.hasTabBar = false
|
||||
}
|
||||
|
||||
// Clear our observations
|
||||
self.tabBarObserver = nil
|
||||
}
|
||||
|
||||
// MARK: NSToolbarDelegate
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.title, .flexibleSpace, .space]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.flexibleSpace, .title, .flexibleSpace]
|
||||
}
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar,
|
||||
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
|
||||
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
switch itemIdentifier {
|
||||
case .title:
|
||||
let item = NSToolbarItem(itemIdentifier: .title)
|
||||
item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel))
|
||||
item.visibilityPriority = .user
|
||||
item.isEnabled = true
|
||||
|
||||
// This is the documented way to avoid the glass view on an item.
|
||||
// We don't want glass on our title.
|
||||
item.isBordered = false
|
||||
|
||||
return item
|
||||
default:
|
||||
return NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SwiftUI
|
||||
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var title: String = "👻 Ghostty"
|
||||
@Published var hasTabBar: Bool = false
|
||||
}
|
||||
}
|
||||
|
||||
extension NSToolbarItem.Identifier {
|
||||
/// Displays the title of the window
|
||||
static let title = NSToolbarItem.Identifier("Title")
|
||||
}
|
||||
|
||||
extension TitlebarTabsTahoeTerminalWindow {
|
||||
/// Displays the window title
|
||||
struct TitleItem: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
|
||||
var title: String {
|
||||
// An empty title makes this view zero-sized and NSToolbar on macOS
|
||||
// tahoe just deletes the item when that happens. So we use a space
|
||||
// instead to ensure there's always some size.
|
||||
return viewModel.title.isEmpty ? " " : viewModel.title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !viewModel.hasTabBar {
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} 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.
|
||||
Color.clear.frame(width: 1, height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,10 @@
|
|||
import Cocoa
|
||||
|
||||
class TerminalWindow: NSWindow {
|
||||
/// This is the key in UserDefaults to use for the default `level` value.
|
||||
static let defaultLevelKey: String = "TerminalDefaultLevel"
|
||||
|
||||
@objc dynamic var keyEquivalent: String = ""
|
||||
|
||||
/// Titlebar tabs for macOS 13 to 15.
|
||||
class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
/// 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.
|
||||
var isLightTheme: Bool = false
|
||||
fileprivate var isLightTheme: Bool = false
|
||||
|
||||
lazy var titlebarColor: NSColor = backgroundColor {
|
||||
didSet {
|
||||
|
|
@ -18,131 +14,39 @@ class TerminalWindow: NSWindow {
|
|||
}
|
||||
}
|
||||
|
||||
private lazy var keyEquivalentLabel: NSTextField = {
|
||||
let label = NSTextField(labelWithAttributedString: NSAttributedString())
|
||||
label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
|
||||
label.postsFrameChangedNotifications = true
|
||||
// false if all three traffic lights are missing/hidden, otherwise true
|
||||
private var hasWindowButtons: Bool {
|
||||
get {
|
||||
// if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true
|
||||
let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true
|
||||
let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true
|
||||
let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true
|
||||
return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden)
|
||||
}
|
||||
}
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var bindings = [
|
||||
observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in
|
||||
guard let tabGroup = self?.tabGroup else { return }
|
||||
|
||||
self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed
|
||||
self?.updateResetZoomTitlebarButtonVisibility()
|
||||
},
|
||||
|
||||
observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
||||
.foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
||||
]
|
||||
let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes)
|
||||
|
||||
self?.keyEquivalentLabel.attributedStringValue = attributedString
|
||||
},
|
||||
]
|
||||
|
||||
// Both of these must be true for windows without decorations to be able to
|
||||
// still become key/main and receive events.
|
||||
override var canBecomeKey: Bool { return true }
|
||||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
// MARK: NSWindow
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
_ = bindings
|
||||
|
||||
// Create the tab accessory view that houses the key-equivalent label and optional un-zoom button
|
||||
let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
|
||||
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
stackView.spacing = 3
|
||||
tab.accessoryView = stackView
|
||||
|
||||
if titlebarTabs {
|
||||
generateToolbar()
|
||||
}
|
||||
|
||||
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
|
||||
}
|
||||
|
||||
deinit {
|
||||
bindings.forEach() { $0.invalidate() }
|
||||
}
|
||||
|
||||
// MARK: Titlebar Helpers
|
||||
// These helpers are generic to what we're trying to achieve (i.e. titlebar
|
||||
// style tabs, titlebar styling, etc.). They're just here to make it easier.
|
||||
|
||||
private var titlebarContainer: NSView? {
|
||||
// If we aren't fullscreen then the titlebar container is part of our window.
|
||||
if !styleMask.contains(.fullScreen) {
|
||||
guard let view = contentView?.superview ?? contentView else { return nil }
|
||||
return titlebarContainerView(in: view)
|
||||
// Handle titlebar tabs config option. Something about what we do while setting up the
|
||||
// titlebar tabs interferes with the window restore process unless window.tabbingMode
|
||||
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
|
||||
tabbingMode = .preferred
|
||||
DispatchQueue.main.async {
|
||||
self.tabbingMode = .automatic
|
||||
}
|
||||
|
||||
// If we are fullscreen, the titlebar container view is part of a separate
|
||||
// "fullscreen window", we need to find the window and then get the view.
|
||||
for window in NSApplication.shared.windows {
|
||||
// This is the private window class that contains the toolbar
|
||||
guard window.className == "NSToolbarFullScreenWindow" else { continue }
|
||||
titlebarTabs = true
|
||||
|
||||
// The parent will match our window. This is used to filter the correct
|
||||
// fullscreen window if we have multiple.
|
||||
guard window.parent == self else { continue }
|
||||
// Set the background color of the window
|
||||
backgroundColor = derivedConfig.backgroundColor
|
||||
|
||||
guard let view = window.contentView else { continue }
|
||||
return titlebarContainerView(in: view)
|
||||
}
|
||||
|
||||
return nil
|
||||
// This makes sure our titlebar renders correctly when there is a transparent background
|
||||
titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||
}
|
||||
|
||||
private func titlebarContainerView(in view: NSView) -> NSView? {
|
||||
if view.className == "NSTitlebarContainerView" {
|
||||
return view
|
||||
}
|
||||
|
||||
for subview in view.subviews {
|
||||
if let found = titlebarContainerView(in: subview) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - NSWindow
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
}
|
||||
|
||||
// We override this so that with the hidden titlebar style the titlebar
|
||||
// area is not draggable.
|
||||
override var contentLayoutRect: CGRect {
|
||||
var rect = super.contentLayoutRect
|
||||
|
||||
// If we are using a hidden titlebar style, the content layout is the
|
||||
// full frame making it so that it is not draggable.
|
||||
if let controller = windowController as? TerminalController,
|
||||
controller.derivedConfig.macosTitlebarStyle == "hidden" {
|
||||
rect.origin.y = 0
|
||||
rect.size.height = self.frame.height
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
// The window theme configuration from Ghostty. This is used to control some
|
||||
// behaviors that don't look quite right in certain situations.
|
||||
var windowTheme: TerminalWindowTheme?
|
||||
|
||||
// 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
|
||||
|
|
@ -153,13 +57,12 @@ class TerminalWindow: NSWindow {
|
|||
// This is required because the removeTitlebarAccessoryViewController hook does not
|
||||
// catch the creation of a new window by "tearing off" a tab from a tabbed window.
|
||||
if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 {
|
||||
hideCustomTabBarViews()
|
||||
resetCustomTabBarViews()
|
||||
}
|
||||
|
||||
super.becomeKey()
|
||||
|
||||
updateNewTabButtonOpacity()
|
||||
resetZoomTabButton.contentTintColor = .controlAccentColor
|
||||
resetZoomToolbarButton.contentTintColor = .controlAccentColor
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
|
|
@ -168,7 +71,6 @@ class TerminalWindow: NSWindow {
|
|||
super.resignKey()
|
||||
|
||||
updateNewTabButtonOpacity()
|
||||
resetZoomTabButton.contentTintColor = .secondaryLabelColor
|
||||
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
|
|
@ -197,11 +99,6 @@ class TerminalWindow: NSWindow {
|
|||
}
|
||||
}
|
||||
|
||||
updateResetZoomTitlebarButtonVisibility()
|
||||
|
||||
// The remainder of this function only applies to styled tabs.
|
||||
guard hasStyledTabs else { return }
|
||||
|
||||
titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none
|
||||
if titlebarTabs {
|
||||
hideToolbarOverflowButton()
|
||||
|
|
@ -246,20 +143,29 @@ class TerminalWindow: NSWindow {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar Styling
|
||||
// MARK: Appearance
|
||||
|
||||
// This is true if we should apply styles to the titlebar or tab bar.
|
||||
var hasStyledTabs: Bool {
|
||||
// If we have titlebar tabs then we always style.
|
||||
guard !titlebarTabs else { return true }
|
||||
override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
super.syncAppearance(surfaceConfig)
|
||||
|
||||
// We style the tabs if they're transparent
|
||||
return transparentTabs
|
||||
// Update our window light/darkness based on our updated background color
|
||||
isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
||||
|
||||
// Update our titlebar color
|
||||
if let preferredBackgroundColor {
|
||||
titlebarColor = preferredBackgroundColor
|
||||
} else {
|
||||
titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||
}
|
||||
|
||||
if (isOpaque) {
|
||||
// If there is transparency, calling this will make the titlebar opaque
|
||||
// so we only call this if we are opaque.
|
||||
updateTabBar()
|
||||
}
|
||||
}
|
||||
|
||||
// Set to true if the background color should bleed through the titlebar/tab bar.
|
||||
// This only applies to non-titlebar tabs.
|
||||
var transparentTabs: Bool = false
|
||||
// MARK: Tab Bar Styling
|
||||
|
||||
var hasVeryDarkBackground: Bool {
|
||||
backgroundColor.luminance < 0.05
|
||||
|
|
@ -274,8 +180,7 @@ class TerminalWindow: NSWindow {
|
|||
// We can only update titlebar tabs if there is a titlebar. Without the
|
||||
// styleMask check the app will crash (issue #1876)
|
||||
if titlebarTabs && styleMask.contains(.titled) {
|
||||
guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return }
|
||||
|
||||
guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return }
|
||||
tabBarAccessoryViewController.layoutAttribute = .right
|
||||
pushTabsToTitlebar(tabBarAccessoryViewController)
|
||||
}
|
||||
|
|
@ -342,53 +247,8 @@ class TerminalWindow: NSWindow {
|
|||
|
||||
// MARK: - Split Zoom Button
|
||||
|
||||
@objc dynamic var surfaceIsZoomed: Bool = false
|
||||
|
||||
private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton()
|
||||
|
||||
private lazy var resetZoomTabButton: NSButton = {
|
||||
let button = generateResetZoomButton()
|
||||
button.action = #selector(selectTabAndZoom(_:))
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = {
|
||||
guard let titlebarContainer else { return nil }
|
||||
let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height)
|
||||
let view = NSView(frame: NSRect(origin: .zero, size: size))
|
||||
|
||||
let button = generateResetZoomButton()
|
||||
button.frame.origin.x = size.width/2 - button.bounds.width/2
|
||||
button.frame.origin.y = size.height/2 - button.bounds.height/2
|
||||
view.addSubview(button)
|
||||
|
||||
let titlebarAccessoryViewController = NSTitlebarAccessoryViewController()
|
||||
titlebarAccessoryViewController.view = view
|
||||
titlebarAccessoryViewController.layoutAttribute = .right
|
||||
|
||||
return titlebarAccessoryViewController
|
||||
}()
|
||||
|
||||
private func updateResetZoomTitlebarButtonVisibility() {
|
||||
guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return }
|
||||
|
||||
let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
|
||||
|
||||
if titlebarTabs {
|
||||
resetZoomToolbarButton.isHidden = isHidden
|
||||
|
||||
for (index, vc) in titlebarAccessoryViewControllers.enumerated() {
|
||||
guard vc == resetZoomTitlebarAccessoryViewController else { return }
|
||||
removeTitlebarAccessoryViewController(at: index)
|
||||
}
|
||||
} else {
|
||||
if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
|
||||
addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
|
||||
}
|
||||
resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden
|
||||
}
|
||||
}
|
||||
|
||||
private func generateResetZoomButton() -> NSButton {
|
||||
let button = NSButton()
|
||||
button.target = nil
|
||||
|
|
@ -424,46 +284,19 @@ class TerminalWindow: NSWindow {
|
|||
// MARK: - Titlebar Font
|
||||
|
||||
// Used to set the titlebar font.
|
||||
var titlebarFont: NSFont? {
|
||||
override var titlebarFont: NSFont? {
|
||||
didSet {
|
||||
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
|
||||
|
||||
titlebarTextField?.font = font
|
||||
tab.attributedTitle = attributedTitle
|
||||
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleFont = font
|
||||
}
|
||||
guard let toolbar = toolbar as? TerminalToolbar else { return }
|
||||
toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the NSTextField responsible for displaying the titlebar's title.
|
||||
private var titlebarTextField: NSTextField? {
|
||||
guard let titlebarView = titlebarContainer?.subviews
|
||||
.first(where: { $0.className == "NSTitlebarView" }) else { return nil }
|
||||
return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField
|
||||
}
|
||||
|
||||
// Return a styled representation of our title property.
|
||||
private var attributedTitle: NSAttributedString? {
|
||||
guard let titlebarFont else { return nil }
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: titlebarFont,
|
||||
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
||||
]
|
||||
return NSAttributedString(string: title, attributes: attributes)
|
||||
}
|
||||
|
||||
// MARK: - Titlebar Tabs
|
||||
|
||||
private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil
|
||||
|
||||
private var windowDragHandle: WindowDragView? = nil
|
||||
|
||||
// The tab bar controller ID from macOS
|
||||
static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController")
|
||||
|
||||
// Used by the window controller to enable/disable titlebar tabs.
|
||||
var titlebarTabs = false {
|
||||
didSet {
|
||||
|
|
@ -476,6 +309,18 @@ class TerminalWindow: NSWindow {
|
|||
}
|
||||
}
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
// Updating the title text as above automatically reveals the
|
||||
// native title view in macOS 15.0 and above. Since we're using
|
||||
// a custom view instead, we need to re-hide it.
|
||||
titleVisibility = .hidden
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleText = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have to regenerate a toolbar when the titlebar tabs setting changes since our
|
||||
// custom toolbar conditionally generates the items based on this setting. I tried to
|
||||
// invalidate the toolbar items and force a refresh, but as far as I can tell that
|
||||
|
|
@ -491,7 +336,6 @@ class TerminalWindow: NSWindow {
|
|||
resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
||||
resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
}
|
||||
updateResetZoomTitlebarButtonVisibility()
|
||||
}
|
||||
|
||||
// For titlebar tabs, we want to hide the separator view so that we get rid
|
||||
|
|
@ -520,10 +364,7 @@ class TerminalWindow: NSWindow {
|
|||
// 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) {
|
||||
let isTabBar = self.titlebarTabs && (
|
||||
childViewController.layoutAttribute == .bottom ||
|
||||
childViewController.identifier == Self.TabBarController
|
||||
)
|
||||
let isTabBar = self.titlebarTabs && isTabBar(childViewController)
|
||||
|
||||
if (isTabBar) {
|
||||
// Ensure it has the right layoutAttribute to force it next to our titlebar
|
||||
|
|
@ -535,7 +376,7 @@ class TerminalWindow: NSWindow {
|
|||
|
||||
// Mark the controller for future reference so we can easily find it. Otherwise
|
||||
// the tab bar has no ID by default.
|
||||
childViewController.identifier = Self.TabBarController
|
||||
childViewController.identifier = Self.tabBarIdentifier
|
||||
}
|
||||
|
||||
super.addTitlebarAccessoryViewController(childViewController)
|
||||
|
|
@ -546,20 +387,25 @@ class TerminalWindow: NSWindow {
|
|||
}
|
||||
|
||||
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||
let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController
|
||||
let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier
|
||||
super.removeTitlebarAccessoryViewController(at: index)
|
||||
if (isTabBar) {
|
||||
hideCustomTabBarViews()
|
||||
resetCustomTabBarViews()
|
||||
}
|
||||
}
|
||||
|
||||
// To be called immediately after the tab bar is disabled.
|
||||
private func hideCustomTabBarViews() {
|
||||
private func resetCustomTabBarViews() {
|
||||
// Hide the window buttons backdrop.
|
||||
windowButtonsBackdrop?.isHidden = true
|
||||
|
||||
// Hide the window drag handle.
|
||||
windowDragHandle?.isHidden = true
|
||||
|
||||
// Reenable the main toolbar title
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleIsHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) {
|
||||
|
|
@ -568,6 +414,11 @@ class TerminalWindow: NSWindow {
|
|||
generateToolbar()
|
||||
}
|
||||
|
||||
// The main title conflicts with titlebar tabs, so hide it
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleIsHidden = true
|
||||
}
|
||||
|
||||
// HACK: wait a tick before doing anything, to avoid edge cases during startup... :/
|
||||
// If we don't do this then on launch windows with restored state with tabs will end
|
||||
// up with messed up tab bars that don't show all tabs.
|
||||
|
|
@ -614,7 +465,7 @@ class TerminalWindow: NSWindow {
|
|||
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
|
||||
view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true
|
||||
view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: hasWindowButtons ? 78 : 0).isActive = true
|
||||
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
|
||||
view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
|
||||
|
||||
|
|
@ -692,7 +543,7 @@ fileprivate class WindowDragView: NSView {
|
|||
fileprivate class WindowButtonsBackdropView: NSView {
|
||||
// This must be weak because the window has this view. Otherwise
|
||||
// a retain cycle occurs.
|
||||
private weak var terminalWindow: TerminalWindow?
|
||||
private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow?
|
||||
private let isLightTheme: Bool
|
||||
private let overlayLayer = VibrantLayer()
|
||||
|
||||
|
|
@ -720,7 +571,7 @@ fileprivate class WindowButtonsBackdropView: NSView {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
init(window: TerminalWindow) {
|
||||
init(window: TitlebarTabsVenturaTerminalWindow) {
|
||||
self.terminalWindow = window
|
||||
self.isLightTheme = window.isLightTheme
|
||||
|
||||
|
|
@ -736,9 +587,133 @@ fileprivate class WindowButtonsBackdropView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
enum TerminalWindowTheme: String {
|
||||
case auto
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
// MARK: Toolbar
|
||||
|
||||
// Custom NSToolbar subclass that displays a centered window title,
|
||||
// in order to accommodate the titlebar tabs feature.
|
||||
fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate {
|
||||
private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
|
||||
|
||||
var titleText: String {
|
||||
get {
|
||||
titleTextField.stringValue
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.stringValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var titleFont: NSFont? {
|
||||
get {
|
||||
titleTextField.font
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.font = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var titleIsHidden: Bool {
|
||||
get {
|
||||
titleTextField.isHidden
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.isHidden = newValue
|
||||
}
|
||||
}
|
||||
|
||||
override init(identifier: NSToolbar.Identifier) {
|
||||
super.init(identifier: identifier)
|
||||
|
||||
delegate = self
|
||||
centeredItemIdentifiers.insert(.titleText)
|
||||
}
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar,
|
||||
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
|
||||
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
var item: NSToolbarItem
|
||||
|
||||
switch itemIdentifier {
|
||||
case .titleText:
|
||||
item = NSToolbarItem(itemIdentifier: .titleText)
|
||||
item.view = self.titleTextField
|
||||
item.visibilityPriority = .user
|
||||
|
||||
// This ensures the title text field doesn't disappear when shrinking the view
|
||||
self.titleTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
// Add constraints to the toolbar item's view
|
||||
NSLayoutConstraint.activate([
|
||||
// Set the height constraint to match the toolbar's height
|
||||
self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed
|
||||
])
|
||||
|
||||
item.isEnabled = true
|
||||
case .resetZoom:
|
||||
item = NSToolbarItem(itemIdentifier: .resetZoom)
|
||||
default:
|
||||
item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.titleText, .flexibleSpace, .space, .resetZoom]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
// These space items are here to ensure that the title remains centered when it starts
|
||||
// getting smaller than the max size so starts clipping. Lucky for us, two of the
|
||||
// built-in spacers plus the un-zoom button item seems to exactly match the space
|
||||
// on the left that's reserved for the window buttons.
|
||||
return [.flexibleSpace, .titleText, .flexibleSpace]
|
||||
}
|
||||
}
|
||||
|
||||
/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window.
|
||||
fileprivate class CenteredDynamicLabel: NSTextField {
|
||||
override func viewDidMoveToSuperview() {
|
||||
// Configure the text field
|
||||
isEditable = false
|
||||
isBordered = false
|
||||
drawsBackground = false
|
||||
alignment = .center
|
||||
lineBreakMode = .byTruncatingTail
|
||||
cell?.truncatesLastVisibleLine = true
|
||||
|
||||
// Use Auto Layout
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Set content hugging and compression resistance priorities
|
||||
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
}
|
||||
|
||||
// Vertically center the text
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else {
|
||||
super.draw(dirtyRect)
|
||||
return
|
||||
}
|
||||
|
||||
let textSize = attributedString.size()
|
||||
|
||||
let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better
|
||||
|
||||
let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset,
|
||||
width: self.bounds.width, height: textSize.height)
|
||||
|
||||
attributedString.draw(in: centeredRect)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSToolbarItem.Identifier {
|
||||
static let resetZoom = NSToolbarItem.Identifier("ResetZoom")
|
||||
static let titleText = NSToolbarItem.Identifier("TitleText")
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import AppKit
|
||||
|
||||
/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar
|
||||
/// matches the background color of the window.
|
||||
class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
/// Stores the last surface configuration to reapply appearance when needed.
|
||||
/// 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() {
|
||||
super.awakeFromNib()
|
||||
|
||||
// Setup all the KVO we will use, see the docs for the respective functions
|
||||
// to learn why we need KVO.
|
||||
setupKVO()
|
||||
}
|
||||
|
||||
override func becomeMain() {
|
||||
super.becomeMain()
|
||||
|
||||
guard let lastSurfaceConfig else { return }
|
||||
syncAppearance(lastSurfaceConfig)
|
||||
|
||||
// This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar
|
||||
// automatically disappears, then we need to resync our appearance because
|
||||
// at some point macOS replaces the tab views.
|
||||
if tabGroup?.windows.count ?? 0 == 2 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
|
||||
self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func update() {
|
||||
super.update()
|
||||
|
||||
// On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our
|
||||
// titlebar to be truly transparent.
|
||||
if #unavailable(macOS 26) {
|
||||
if !effectViewIsHidden {
|
||||
hideEffectView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
|
||||
override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
super.syncAppearance(surfaceConfig)
|
||||
|
||||
// Save our config in case we need to reapply
|
||||
lastSurfaceConfig = surfaceConfig
|
||||
|
||||
// Everytime we change appearance, set KVO up again in case any of our
|
||||
// references changed (e.g. tabGroup is new).
|
||||
setupKVO()
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
syncAppearanceTahoe(surfaceConfig)
|
||||
} else {
|
||||
syncAppearanceVentura(surfaceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
// When we have transparency, we need to set the titlebar background to match the
|
||||
// window background but with opacity. The window background is set using the
|
||||
// "preferred background color" property.
|
||||
//
|
||||
// As an inverse, if we don't have transparency, we don't bother with this because
|
||||
// the window background will be set to the correct color so we can just hide the
|
||||
// titlebar completely and we're good to go.
|
||||
if !isOpaque {
|
||||
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
|
||||
titlebarView.wantsLayer = true
|
||||
titlebarView.layer?.backgroundColor = 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
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
// MARK: View Finders
|
||||
|
||||
private var titlebarBackgroundView: NSView? {
|
||||
titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView")
|
||||
}
|
||||
|
||||
// MARK: Tab Group Observation
|
||||
|
||||
private func setupKVO() {
|
||||
// See the docs for the respective setup functions for why.
|
||||
setupTabGroupObservation()
|
||||
setupTabBarVisibleObservation()
|
||||
}
|
||||
|
||||
/// Monitors the tabGroup windows value for any changes and resyncs the appearance on change.
|
||||
/// This is necessary because when the windows change, the tab bar and titlebar are recreated
|
||||
/// which breaks our changes.
|
||||
private func setupTabGroupObservation() {
|
||||
// Remove existing observation if any
|
||||
tabGroupWindowsObservation?.invalidate()
|
||||
tabGroupWindowsObservation = nil
|
||||
|
||||
// Check if tabGroup is available
|
||||
guard let tabGroup else { return }
|
||||
|
||||
// Set up KVO observation for the windows array. Whenever it changes
|
||||
// we resync the appearance because it can cause macOS to redraw the
|
||||
// tab bar.
|
||||
tabGroupWindowsObservation = tabGroup.observe(
|
||||
\.windows,
|
||||
options: [.new]
|
||||
) { [weak self] _, change in
|
||||
// NOTE: At one point, I guarded this on only if we went from 0 to N
|
||||
// or N to 0 under the assumption that the tab bar would only get
|
||||
// replaced on those cases. This turned out to be false (Tahoe).
|
||||
// It's cheap enough to always redraw this so we should just do it
|
||||
// unconditionally.
|
||||
|
||||
guard let self else { return }
|
||||
guard let lastSurfaceConfig else { return }
|
||||
self.syncAppearance(lastSurfaceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item
|
||||
/// to not break our appearance.
|
||||
private func setupTabBarVisibleObservation() {
|
||||
// Remove existing observation if any
|
||||
tabBarVisibleObservation?.invalidate()
|
||||
tabBarVisibleObservation = nil
|
||||
|
||||
// Set up KVO observation for isTabBarVisible
|
||||
tabBarVisibleObservation = tabGroup?.observe(
|
||||
\.isTabBarVisible,
|
||||
options: [.new]
|
||||
) { [weak self] _, change in
|
||||
guard let self else { return }
|
||||
guard let lastSurfaceConfig else { return }
|
||||
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
|
||||
// would be an opaque color. When the titlebar isn't transparent, however, the system applies
|
||||
// a compositing effect to the unselected tab backgrounds, which makes them blend with the
|
||||
// titlebar's/window's background.
|
||||
if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first {
|
||||
effectView.isHidden = true
|
||||
}
|
||||
|
||||
effectViewIsHidden = true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
enum AppError: Error {
|
||||
case surfaceCreateError
|
||||
}
|
||||
|
|
@ -553,6 +553,12 @@ extension Ghostty {
|
|||
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
|
||||
checkForUpdates(app)
|
||||
|
||||
case GHOSTTY_ACTION_UNDO:
|
||||
return undo(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_REDO:
|
||||
return redo(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
|
||||
|
|
@ -599,6 +605,48 @@ extension Ghostty {
|
|||
}
|
||||
}
|
||||
|
||||
private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
|
||||
let undoManager: UndoManager?
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
undoManager = surfaceView.undoManager
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
guard let undoManager, undoManager.canUndo else { return false }
|
||||
undoManager.undo()
|
||||
return true
|
||||
}
|
||||
|
||||
private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
|
||||
let undoManager: UndoManager?
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
undoManager = surfaceView.undoManager
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
guard let undoManager, undoManager.canRedo else { return false }
|
||||
undoManager.redo()
|
||||
return true
|
||||
}
|
||||
|
||||
private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
|
|
@ -745,7 +793,7 @@ extension Ghostty {
|
|||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let mode = FullscreenMode.from(ghostty: raw) else {
|
||||
Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)")
|
||||
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)")
|
||||
return
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
|
|
@ -921,7 +969,7 @@ extension Ghostty {
|
|||
// we should only be returning true if we actually performed the action,
|
||||
// but this handles the most common case of caring about goto_split performability
|
||||
// which is the no-split case.
|
||||
guard controller.surfaceTree?.isSplit ?? false else { return false }
|
||||
guard controller.surfaceTree.isSplit else { return false }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyFocusSplit,
|
||||
|
|
@ -1082,7 +1130,7 @@ extension Ghostty {
|
|||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let window = surfaceView.window as? TerminalWindow else { return }
|
||||
|
||||
|
||||
switch (mode) {
|
||||
case .on:
|
||||
window.level = .floating
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
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)
|
||||
}
|
||||
|
||||
/// Human-friendly description of what this command will do.
|
||||
var description: String {
|
||||
String(cString: cValue.description)
|
||||
}
|
||||
|
||||
/// The full action that must be performed to invoke this command.
|
||||
var action: String {
|
||||
String(cString: cValue.action)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// True if this can be performed on this target.
|
||||
var isSupported: Bool {
|
||||
!Self.unsupportedActionKeys.contains(actionKey)
|
||||
}
|
||||
|
||||
/// Unsupported action keys, because they either don't make sense in the context of our
|
||||
/// target platform or they just aren't implemented yet.
|
||||
static let unsupportedActionKeys: [String] = [
|
||||
"toggle_tab_overview",
|
||||
"toggle_window_decorations",
|
||||
"show_gtk_inspector",
|
||||
]
|
||||
|
||||
init(cValue: ghostty_command_s) {
|
||||
self.cValue = cValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +250,17 @@ extension Ghostty {
|
|||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
var macosWindowButtons: MacOSWindowButtons {
|
||||
let defaultValue = MacOSWindowButtons.visible
|
||||
guard let config = self.config else { return defaultValue }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
let key = "macos-window-buttons"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
||||
guard let ptr = v else { return defaultValue }
|
||||
let str = String(cString: ptr)
|
||||
return MacOSWindowButtons(rawValue: str) ?? defaultValue
|
||||
}
|
||||
|
||||
var macosTitlebarStyle: String {
|
||||
let defaultValue = "transparent"
|
||||
guard let config = self.config else { return defaultValue }
|
||||
|
|
@ -495,6 +506,14 @@ extension Ghostty {
|
|||
return v;
|
||||
}
|
||||
|
||||
var undoTimeout: Duration {
|
||||
guard let config = self.config else { return .seconds(5) }
|
||||
var v: UInt = 0
|
||||
let key = "undo-timeout"
|
||||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||
return .milliseconds(v)
|
||||
}
|
||||
|
||||
var autoUpdate: AutoUpdate? {
|
||||
guard let config = self.config else { return nil }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
|
|
@ -539,6 +558,17 @@ extension Ghostty {
|
|||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||
return v
|
||||
}
|
||||
|
||||
var macosShortcuts: MacShortcuts {
|
||||
let defaultValue = MacShortcuts.ask
|
||||
guard let config = self.config else { return defaultValue }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
let key = "macos-shortcuts"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
||||
guard let ptr = v else { return defaultValue }
|
||||
let str = String(cString: ptr)
|
||||
return MacShortcuts(rawValue: str) ?? defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +585,9 @@ extension Ghostty.Config {
|
|||
let rawValue: CUnsignedInt
|
||||
|
||||
static let system = BellFeatures(rawValue: 1 << 0)
|
||||
static let audio = BellFeatures(rawValue: 1 << 1)
|
||||
static let attention = BellFeatures(rawValue: 1 << 2)
|
||||
static let title = BellFeatures(rawValue: 1 << 3)
|
||||
}
|
||||
|
||||
enum MacHidden : String {
|
||||
|
|
@ -562,6 +595,12 @@ extension Ghostty.Config {
|
|||
case always
|
||||
}
|
||||
|
||||
enum MacShortcuts: String {
|
||||
case allow
|
||||
case deny
|
||||
case ask
|
||||
}
|
||||
|
||||
enum ResizeOverlay : String {
|
||||
case always
|
||||
case never
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
extension Ghostty {
|
||||
/// Possible errors from internal Ghostty calls.
|
||||
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
|
||||
case apiFailed
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .apiFailed: return "libghostty API call failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,494 +0,0 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
/// This enum represents the possible states that a node in the split tree can be in. It is either:
|
||||
///
|
||||
/// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
|
||||
/// terminal surface to render.
|
||||
/// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
|
||||
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
|
||||
/// values can further be split infinitely.
|
||||
///
|
||||
enum SplitNode: Equatable, Hashable, Codable, Sequence {
|
||||
case leaf(Leaf)
|
||||
case split(Container)
|
||||
|
||||
/// The parent of this node.
|
||||
var parent: Container? {
|
||||
get {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
return leaf.parent
|
||||
|
||||
case .split(let container):
|
||||
return container.parent
|
||||
}
|
||||
}
|
||||
|
||||
set {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
leaf.parent = newValue
|
||||
|
||||
case .split(let container):
|
||||
container.parent = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the tree is split.
|
||||
var isSplit: Bool {
|
||||
return if case .leaf = self {
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
func topLeft() -> SurfaceView {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
return leaf.surface
|
||||
|
||||
case .split(let container):
|
||||
return container.topLeft.topLeft()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the view that would prefer receiving focus in this tree. This is always the
|
||||
/// top-left-most view. This is used when creating a split or closing a split to find the
|
||||
/// next view to send focus to.
|
||||
func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
|
||||
let container: Container
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
// noSplit is easy because there is only one thing to focus
|
||||
return leaf.surface
|
||||
|
||||
case .split(let c):
|
||||
container = c
|
||||
}
|
||||
|
||||
let node: SplitNode
|
||||
switch (direction) {
|
||||
case .previous, .up, .left:
|
||||
node = container.bottomRight
|
||||
|
||||
case .next, .down, .right:
|
||||
node = container.topLeft
|
||||
}
|
||||
|
||||
return node.preferredFocus(direction)
|
||||
}
|
||||
|
||||
/// When direction is either next or previous, return the first or last
|
||||
/// leaf. This can be used when the focus needs to move to a leaf even
|
||||
/// after hitting the bottom-right-most or top-left-most surface.
|
||||
/// When the direction is not next or previous (such as top, bottom,
|
||||
/// left, right), it will be ignored and no leaf will be returned.
|
||||
func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? {
|
||||
// If there is no parent, simply ignore.
|
||||
guard let root = self.parent?.rootContainer() else { return nil }
|
||||
|
||||
switch (direction) {
|
||||
case .next:
|
||||
return root.firstLeaf()
|
||||
case .previous:
|
||||
return root.lastLeaf()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the surface associated with this node. This will likely deinitialize the
|
||||
/// surface. At this point, the surface view in this node tree can never be used again.
|
||||
func close() {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
leaf.surface.close()
|
||||
|
||||
case .split(let container):
|
||||
container.topLeft.close()
|
||||
container.bottomRight.close()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if any surface in the split stack requires quit confirmation.
|
||||
func needsConfirmQuit() -> Bool {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
return leaf.surface.needsConfirmQuit
|
||||
|
||||
case .split(let container):
|
||||
return container.topLeft.needsConfirmQuit() ||
|
||||
container.bottomRight.needsConfirmQuit()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the split tree contains the given view.
|
||||
func contains(view: SurfaceView) -> Bool {
|
||||
return leaf(for: view) != nil
|
||||
}
|
||||
|
||||
/// Find a surface view by UUID.
|
||||
func findUUID(uuid: UUID) -> SurfaceView? {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
if (leaf.surface.uuid == uuid) {
|
||||
return leaf.surface
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case .split(let container):
|
||||
return container.topLeft.findUUID(uuid: uuid) ??
|
||||
container.bottomRight.findUUID(uuid: uuid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the surface borders the top. Assumes the view is in the tree.
|
||||
func doesBorderTop(view: SurfaceView) -> Bool {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
return leaf.surface == view
|
||||
|
||||
case .split(let container):
|
||||
switch (container.direction) {
|
||||
case .vertical:
|
||||
return container.topLeft.doesBorderTop(view: view)
|
||||
|
||||
case .horizontal:
|
||||
return container.topLeft.doesBorderTop(view: view) ||
|
||||
container.bottomRight.doesBorderTop(view: view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the node for the given view if its in the tree.
|
||||
func leaf(for view: SurfaceView) -> Leaf? {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
if leaf.surface == view {
|
||||
return leaf
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
case .split(let container):
|
||||
return container.topLeft.leaf(for: view) ??
|
||||
container.bottomRight.leaf(for: view)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sequence
|
||||
|
||||
func makeIterator() -> IndexingIterator<[Leaf]> {
|
||||
return leaves().makeIterator()
|
||||
}
|
||||
|
||||
/// Return all the leaves in this split node. This isn't very efficient but our split trees are never super
|
||||
/// deep so its not an issue.
|
||||
private func leaves() -> [Leaf] {
|
||||
switch (self) {
|
||||
case .leaf(let leaf):
|
||||
return [leaf]
|
||||
|
||||
case .split(let container):
|
||||
return container.topLeft.leaves() + container.bottomRight.leaves()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.leaf(let lhs_v), .leaf(let rhs_v)):
|
||||
return lhs_v === rhs_v
|
||||
case (.split(let lhs_v), .split(let rhs_v)):
|
||||
return lhs_v === rhs_v
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class Leaf: ObservableObject, Equatable, Hashable, Codable {
|
||||
let app: ghostty_app_t
|
||||
@Published var surface: SurfaceView
|
||||
|
||||
weak var parent: SplitNode.Container?
|
||||
|
||||
/// Initialize a new leaf which creates a new terminal surface.
|
||||
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
|
||||
self.app = app
|
||||
self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid)
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(app)
|
||||
hasher.combine(surface)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
static func == (lhs: Leaf, rhs: Leaf) -> Bool {
|
||||
return lhs.app == rhs.app && lhs.surface === rhs.surface
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pwd
|
||||
case uuid
|
||||
}
|
||||
|
||||
required convenience init(from decoder: Decoder) throws {
|
||||
// Decoding uses the global Ghostty app
|
||||
guard let del = NSApplication.shared.delegate,
|
||||
let appDel = del as? AppDelegate,
|
||||
let app = appDel.ghostty.app else {
|
||||
throw TerminalRestoreError.delegateInvalid
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
|
||||
var config = SurfaceConfiguration()
|
||||
config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
|
||||
|
||||
self.init(app, baseConfig: config, uuid: uuid)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(surface.pwd, forKey: .pwd)
|
||||
try container.encode(surface.uuid.uuidString, forKey: .uuid)
|
||||
}
|
||||
}
|
||||
|
||||
class Container: ObservableObject, Equatable, Hashable, Codable {
|
||||
let app: ghostty_app_t
|
||||
let direction: SplitViewDirection
|
||||
|
||||
@Published var topLeft: SplitNode
|
||||
@Published var bottomRight: SplitNode
|
||||
@Published var split: CGFloat = 0.5
|
||||
|
||||
var resizeEvent: PassthroughSubject<Double, Never> = .init()
|
||||
|
||||
weak var parent: SplitNode.Container?
|
||||
|
||||
/// A container is always initialized from some prior leaf because a split has to originate
|
||||
/// from a non-split value. When initializing, we inherit the leaf's surface and then
|
||||
/// initialize a new surface for the new pane.
|
||||
init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) {
|
||||
self.app = from.app
|
||||
self.direction = direction
|
||||
self.parent = from.parent
|
||||
|
||||
// Initially, both topLeft and bottomRight are in the "nosplit"
|
||||
// state since this is a new split.
|
||||
self.topLeft = .leaf(from)
|
||||
|
||||
let bottomRight: Leaf = .init(app, baseConfig: baseConfig)
|
||||
self.bottomRight = .leaf(bottomRight)
|
||||
|
||||
from.parent = self
|
||||
bottomRight.parent = self
|
||||
}
|
||||
|
||||
// Move the top left node to the bottom right and vice versa,
|
||||
// preserving the size.
|
||||
func swap() {
|
||||
let topLeft: SplitNode = self.topLeft
|
||||
self.topLeft = bottomRight
|
||||
self.bottomRight = topLeft
|
||||
self.split = 1 - self.split
|
||||
}
|
||||
|
||||
/// Resize the split by moving the split divider in the given
|
||||
/// direction by the given amount. If this container is not split
|
||||
/// in the given direction, navigate up the tree until we find a
|
||||
/// container that is
|
||||
func resize(direction: SplitResizeDirection, amount: UInt16) {
|
||||
// We send a resize event to our publisher which will be
|
||||
// received by the SplitView.
|
||||
switch (self.direction) {
|
||||
case .horizontal:
|
||||
switch (direction) {
|
||||
case .left: resizeEvent.send(-Double(amount))
|
||||
case .right: resizeEvent.send(Double(amount))
|
||||
default: parent?.resize(direction: direction, amount: amount)
|
||||
}
|
||||
case .vertical:
|
||||
switch (direction) {
|
||||
case .up: resizeEvent.send(-Double(amount))
|
||||
case .down: resizeEvent.send(Double(amount))
|
||||
default: parent?.resize(direction: direction, amount: amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Equalize the splits in this container. Each split is equalized
|
||||
/// based on its weight, i.e. the number of leaves it contains.
|
||||
/// This function returns the weight of this container.
|
||||
func equalize() -> UInt {
|
||||
let topLeftWeight: UInt
|
||||
switch (topLeft) {
|
||||
case .leaf:
|
||||
topLeftWeight = 1
|
||||
case .split(let c):
|
||||
topLeftWeight = c.equalize()
|
||||
}
|
||||
|
||||
let bottomRightWeight: UInt
|
||||
switch (bottomRight) {
|
||||
case .leaf:
|
||||
bottomRightWeight = 1
|
||||
case .split(let c):
|
||||
bottomRightWeight = c.equalize()
|
||||
}
|
||||
|
||||
let weight = topLeftWeight + bottomRightWeight
|
||||
split = Double(topLeftWeight) / Double(weight)
|
||||
return weight
|
||||
}
|
||||
|
||||
/// Returns the top most parent, or this container. Because this
|
||||
/// would fall back to use to self, the return value is guaranteed.
|
||||
func rootContainer() -> Container {
|
||||
guard let parent = self.parent else { return self }
|
||||
return parent.rootContainer()
|
||||
}
|
||||
|
||||
/// Returns the first leaf from the given container. This is most
|
||||
/// useful for root container, so that we can find the top-left-most
|
||||
/// leaf.
|
||||
func firstLeaf() -> Leaf {
|
||||
switch (self.topLeft) {
|
||||
case .leaf(let leaf):
|
||||
return leaf
|
||||
case .split(let s):
|
||||
return s.firstLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the last leaf from the given container. This is most
|
||||
/// useful for root container, so that we can find the bottom-right-
|
||||
/// most leaf.
|
||||
func lastLeaf() -> Leaf {
|
||||
switch (self.bottomRight) {
|
||||
case .leaf(let leaf):
|
||||
return leaf
|
||||
case .split(let s):
|
||||
return s.lastLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(app)
|
||||
hasher.combine(direction)
|
||||
hasher.combine(topLeft)
|
||||
hasher.combine(bottomRight)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
static func == (lhs: Container, rhs: Container) -> Bool {
|
||||
return lhs.app == rhs.app &&
|
||||
lhs.direction == rhs.direction &&
|
||||
lhs.topLeft == rhs.topLeft &&
|
||||
lhs.bottomRight == rhs.bottomRight
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case direction
|
||||
case split
|
||||
case topLeft
|
||||
case bottomRight
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
// Decoding uses the global Ghostty app
|
||||
guard let del = NSApplication.shared.delegate,
|
||||
let appDel = del as? AppDelegate,
|
||||
let app = appDel.ghostty.app else {
|
||||
throw TerminalRestoreError.delegateInvalid
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.app = app
|
||||
self.direction = try container.decode(SplitViewDirection.self, forKey: .direction)
|
||||
self.split = try container.decode(CGFloat.self, forKey: .split)
|
||||
self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft)
|
||||
self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight)
|
||||
|
||||
// Fix up the parent references
|
||||
self.topLeft.parent = self
|
||||
self.bottomRight.parent = self
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(direction, forKey: .direction)
|
||||
try container.encode(split, forKey: .split)
|
||||
try container.encode(topLeft, forKey: .topLeft)
|
||||
try container.encode(bottomRight, forKey: .bottomRight)
|
||||
}
|
||||
}
|
||||
|
||||
/// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
|
||||
/// nodes. This is purposely weak so we don't have to worry about memory management
|
||||
/// with this (although, it should always be correct).
|
||||
struct Neighbors {
|
||||
var left: SplitNode?
|
||||
var right: SplitNode?
|
||||
var up: SplitNode?
|
||||
var down: SplitNode?
|
||||
|
||||
/// These are the previous/next nodes. It will certainly be one of the above as well
|
||||
/// but we keep track of these separately because depending on the split direction
|
||||
/// of the containing node, previous may be left OR up (same for next).
|
||||
var previous: SplitNode?
|
||||
var next: SplitNode?
|
||||
|
||||
/// No neighbors, used by the root node.
|
||||
static let empty: Self = .init()
|
||||
|
||||
/// Get the node for a given direction.
|
||||
func get(direction: SplitFocusDirection) -> SplitNode? {
|
||||
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
|
||||
.previous: \.previous,
|
||||
.next: \.next,
|
||||
.up: \.up,
|
||||
.down: \.down,
|
||||
.left: \.left,
|
||||
.right: \.right,
|
||||
]
|
||||
|
||||
guard let path = map[direction] else { return nil }
|
||||
return self[keyPath: path]
|
||||
}
|
||||
|
||||
/// Update multiple keys and return a new copy.
|
||||
func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
|
||||
var clone = self
|
||||
attrs.forEach { (key, value) in
|
||||
clone[keyPath: key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
/// True if there are no neighbors
|
||||
func isEmpty() -> Bool {
|
||||
return self.previous == nil && self.next == nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
/// Represents a single surface within Ghostty.
|
||||
///
|
||||
/// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor
|
||||
/// of our Ghostty data model. At the time of writing there's still a ton of surface
|
||||
/// functionality that is not encapsulated in this class. It is planned to migrate that
|
||||
/// all over.
|
||||
///
|
||||
/// Wraps a `ghostty_surface_t`
|
||||
final class Surface: Sendable {
|
||||
private let surface: ghostty_surface_t
|
||||
|
||||
/// Read the underlying C value for this surface. This is unsafe because the value will be
|
||||
/// freed when the Surface class is deinitialized.
|
||||
var unsafeCValue: ghostty_surface_t {
|
||||
surface
|
||||
}
|
||||
|
||||
/// Initialize from the C structure.
|
||||
init(cSurface: ghostty_surface_t) {
|
||||
self.surface = cSurface
|
||||
}
|
||||
|
||||
deinit {
|
||||
// deinit is not guaranteed to happen on the main actor and our API
|
||||
// calls into libghostty must happen there so we capture the surface
|
||||
// value so we don't capture `self` and then we detach it in a task.
|
||||
// We can't wait for the task to succeed so this will happen sometime
|
||||
// but that's okay.
|
||||
let surface = self.surface
|
||||
Task.detached { @MainActor in
|
||||
ghostty_surface_free(surface)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard
|
||||
/// shortcuts and other encodings do not take effect.
|
||||
@MainActor
|
||||
func sendText(_ text: String) {
|
||||
let len = text.utf8CString.count
|
||||
if (len == 0) { return }
|
||||
|
||||
text.withCString { ptr in
|
||||
// len includes the null terminator so we do len - 1
|
||||
ghostty_surface_text(surface, ptr, UInt(len - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a key event to the terminal.
|
||||
///
|
||||
/// This sends the full key event including modifiers, action type, and text to the terminal.
|
||||
/// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal
|
||||
/// encoding based on the complete key event information.
|
||||
///
|
||||
/// - Parameter event: The key event to send to the terminal
|
||||
@MainActor
|
||||
func sendKeyEvent(_ event: Input.KeyEvent) {
|
||||
event.withCValue { cEvent in
|
||||
ghostty_surface_key(surface, cEvent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the terminal has captured mouse input.
|
||||
///
|
||||
/// When the mouse is captured, the terminal application is receiving mouse events
|
||||
/// directly rather than the host system handling them. This typically occurs when
|
||||
/// a terminal application enables mouse reporting mode.
|
||||
@MainActor
|
||||
var mouseCaptured: Bool {
|
||||
ghostty_surface_mouse_captured(surface)
|
||||
}
|
||||
|
||||
/// Send a mouse button event to the terminal.
|
||||
///
|
||||
/// This sends a complete mouse button event including the button state (press/release),
|
||||
/// which button was pressed, and any modifier keys that were held during the event.
|
||||
/// The terminal processes this event according to its mouse handling configuration.
|
||||
///
|
||||
/// - Parameter event: The mouse button event to send to the terminal
|
||||
@MainActor
|
||||
func sendMouseButton(_ event: Input.MouseButtonEvent) {
|
||||
ghostty_surface_mouse_button(
|
||||
surface,
|
||||
event.action.cMouseState,
|
||||
event.button.cMouseButton,
|
||||
event.mods.cMods)
|
||||
}
|
||||
|
||||
/// Send a mouse position event to the terminal.
|
||||
///
|
||||
/// This reports the current mouse position to the terminal, which may be used
|
||||
/// for mouse tracking, hover effects, or other position-dependent features.
|
||||
/// The terminal will only receive these events if mouse reporting is enabled.
|
||||
///
|
||||
/// - Parameter event: The mouse position event to send to the terminal
|
||||
@MainActor
|
||||
func sendMousePos(_ event: Input.MousePosEvent) {
|
||||
ghostty_surface_mouse_pos(
|
||||
surface,
|
||||
event.x,
|
||||
event.y,
|
||||
event.mods.cMods)
|
||||
}
|
||||
|
||||
/// Send a mouse scroll event to the terminal.
|
||||
///
|
||||
/// This sends scroll wheel input to the terminal with delta values for both
|
||||
/// horizontal and vertical scrolling, along with precision and momentum information.
|
||||
/// The terminal processes this according to its scroll handling configuration.
|
||||
///
|
||||
/// - Parameter event: The mouse scroll event to send to the terminal
|
||||
@MainActor
|
||||
func sendMouseScroll(_ event: Input.MouseScrollEvent) {
|
||||
ghostty_surface_mouse_scroll(
|
||||
surface,
|
||||
event.x,
|
||||
event.y,
|
||||
event.mods.cScrollMods)
|
||||
}
|
||||
|
||||
/// Perform a keybinding action.
|
||||
///
|
||||
/// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4`
|
||||
/// you can perform `goto_tab:4` with this.
|
||||
///
|
||||
/// Returns true if the action was performed. Invalid actions return false.
|
||||
@MainActor
|
||||
func perform(action: String) -> Bool {
|
||||
let len = action.utf8CString.count
|
||||
if (len == 0) { return false }
|
||||
return action.withCString { cString in
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
/// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the
|
||||
/// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the
|
||||
/// split direction by splitting the terminal.
|
||||
///
|
||||
/// This also allows one split to be "zoomed" at any time.
|
||||
struct TerminalSplit: View {
|
||||
/// The current state of the root node. This can be set to nil when all surfaces are closed.
|
||||
@Binding var node: SplitNode?
|
||||
|
||||
/// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface
|
||||
/// becomes "full screen" on the split tree.
|
||||
@State private var zoomedSurface: SurfaceView? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
TerminalSplitRoot(
|
||||
node: $node,
|
||||
zoomedSurface: $zoomedSurface
|
||||
)
|
||||
|
||||
// If we have a zoomed surface, we overlay that on top of our split
|
||||
// root. Our split root will become clear when there is a zoomed
|
||||
// surface. We need to keep the split root around so that we don't
|
||||
// lose all of the surface state so this must be a ZStack.
|
||||
if let surfaceView = zoomedSurface {
|
||||
InspectableSurface(surfaceView: surfaceView)
|
||||
}
|
||||
}
|
||||
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
|
||||
/// one of these in a split tree.
|
||||
private struct TerminalSplitRoot: View {
|
||||
/// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close.
|
||||
@Binding var node: SplitNode?
|
||||
|
||||
/// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own
|
||||
/// is in the zoomed state, we clear our body since we expect a zoomed split to overlay
|
||||
/// this one.
|
||||
@Binding var zoomedSurface: SurfaceView?
|
||||
|
||||
var body: some View {
|
||||
let center = NotificationCenter.default
|
||||
let pubZoom = center.publisher(for: Notification.didToggleSplitZoom)
|
||||
|
||||
// If we're zoomed, we don't render anything, we are transparent. This
|
||||
// ensures that the View stays around so we don't lose our state, but
|
||||
// also that the zoomed view on top can see through if background transparency
|
||||
// is enabled.
|
||||
if (zoomedSurface == nil) {
|
||||
ZStack {
|
||||
switch (node) {
|
||||
case nil:
|
||||
Color(.clear)
|
||||
|
||||
case .leaf(let leaf):
|
||||
TerminalSplitLeaf(
|
||||
leaf: leaf,
|
||||
neighbors: .empty,
|
||||
node: $node
|
||||
)
|
||||
|
||||
case .split(let container):
|
||||
TerminalSplitContainer(
|
||||
neighbors: .empty,
|
||||
node: $node,
|
||||
container: container
|
||||
)
|
||||
.onReceive(pubZoom) { onZoom(notification: $0) }
|
||||
}
|
||||
}
|
||||
.id(node) // Needed for change detection on node
|
||||
} else {
|
||||
// On these events we want to reset the split state and call it.
|
||||
let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!)
|
||||
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!)
|
||||
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!)
|
||||
|
||||
ZStack {}
|
||||
.onReceive(pubZoom) { onZoomReset(notification: $0) }
|
||||
.onReceive(pubSplit) { onZoomReset(notification: $0) }
|
||||
.onReceive(pubClose) { onZoomReset(notification: $0) }
|
||||
.onReceive(pubFocus) { onZoomReset(notification: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
func onZoom(notification: SwiftUI.Notification) {
|
||||
// Our node must be split to receive zooms. You can't zoom an unsplit terminal.
|
||||
if case .leaf = node {
|
||||
preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist")
|
||||
}
|
||||
|
||||
// Make sure the notification has a surface and that this window owns the surface.
|
||||
guard let surfaceView = notification.object as? SurfaceView else { return }
|
||||
guard node?.contains(view: surfaceView) ?? false else { return }
|
||||
|
||||
// We are in the zoomed state.
|
||||
zoomedSurface = surfaceView
|
||||
|
||||
// See onZoomReset, same logic.
|
||||
DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) }
|
||||
}
|
||||
|
||||
func onZoomReset(notification: SwiftUI.Notification) {
|
||||
// Make sure the notification has a surface and that this window owns the surface.
|
||||
guard let surfaceView = notification.object as? SurfaceView else { return }
|
||||
guard zoomedSurface == surfaceView else { return }
|
||||
|
||||
// We are now unzoomed
|
||||
zoomedSurface = nil
|
||||
|
||||
// We need to stay focused on this view, but the view is going to change
|
||||
// superviews. We need to do this async so it happens on the next event loop
|
||||
// tick.
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
|
||||
// If the notification is not a toggle zoom notification, we want to re-publish
|
||||
// it after a short delay so that the split tree has a chance to re-establish
|
||||
// so the proper view gets this notification.
|
||||
if (notification.name != Notification.didToggleSplitZoom) {
|
||||
// We have to wait ANOTHER tick since we just established.
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A noSplit leaf node of a split tree.
|
||||
private struct TerminalSplitLeaf: View {
|
||||
/// The leaf to draw the surface for.
|
||||
let leaf: SplitNode.Leaf
|
||||
|
||||
/// The neighbors, used for navigation.
|
||||
let neighbors: SplitNode.Neighbors
|
||||
|
||||
/// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed.
|
||||
@Binding var node: SplitNode?
|
||||
|
||||
var body: some View {
|
||||
let center = NotificationCenter.default
|
||||
let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
|
||||
let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
|
||||
let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface)
|
||||
let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface)
|
||||
|
||||
InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
|
||||
.onReceive(pub) { onNewSplit(notification: $0) }
|
||||
.onReceive(pubClose) { onClose(notification: $0) }
|
||||
.onReceive(pubFocus) { onMoveFocus(notification: $0) }
|
||||
.onReceive(pubResize) { onResize(notification: $0) }
|
||||
}
|
||||
|
||||
private func onClose(notification: SwiftUI.Notification) {
|
||||
var processAlive = false
|
||||
if let valueAny = notification.userInfo?["process_alive"] {
|
||||
if let value = valueAny as? Bool {
|
||||
processAlive = value
|
||||
}
|
||||
}
|
||||
|
||||
// If the child process is not alive, then we exit immediately
|
||||
guard processAlive else {
|
||||
node = nil
|
||||
return
|
||||
}
|
||||
|
||||
// If we don't have a window to attach our modal to, we also exit immediately.
|
||||
// This should NOT happen.
|
||||
guard let window = leaf.surface.window else {
|
||||
node = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
|
||||
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
|
||||
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
|
||||
// so SwiftUI does not update any of the bindings to note that window is no longer
|
||||
// being shown, and provides no callback to detect this.
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Close Terminal?"
|
||||
alert.informativeText = "The terminal still has a running process. If you close the " +
|
||||
"terminal the process will be killed."
|
||||
alert.addButton(withTitle: "Close the Terminal")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
alert.beginSheetModal(for: window, completionHandler: { response in
|
||||
switch (response) {
|
||||
case .alertFirstButtonReturn:
|
||||
alert.window.orderOut(nil)
|
||||
node = nil
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func onNewSplit(notification: SwiftUI.Notification) {
|
||||
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
||||
let config = configAny as? SurfaceConfiguration
|
||||
|
||||
// Determine our desired direction
|
||||
guard let directionAny = notification.userInfo?["direction"] else { return }
|
||||
guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
|
||||
let splitDirection: SplitViewDirection
|
||||
let swap: Bool
|
||||
switch (direction) {
|
||||
case GHOSTTY_SPLIT_DIRECTION_RIGHT:
|
||||
splitDirection = .horizontal
|
||||
swap = false
|
||||
case GHOSTTY_SPLIT_DIRECTION_LEFT:
|
||||
splitDirection = .horizontal
|
||||
swap = true
|
||||
case GHOSTTY_SPLIT_DIRECTION_DOWN:
|
||||
splitDirection = .vertical
|
||||
swap = false
|
||||
case GHOSTTY_SPLIT_DIRECTION_UP:
|
||||
splitDirection = .vertical
|
||||
swap = true
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// Setup our new container since we are now split
|
||||
let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config)
|
||||
|
||||
// Change the parent node. This will trigger the parent to relayout our views.
|
||||
node = .split(container)
|
||||
|
||||
// See moveFocus comment, we have to run this whenever split changes.
|
||||
Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus())
|
||||
|
||||
// If we are swapping, swap now. We do this after our focus event
|
||||
// so that focus is in the right place.
|
||||
if swap {
|
||||
container.swap()
|
||||
}
|
||||
}
|
||||
|
||||
/// This handles the event to move the split focus (i.e. previous/next) from a keyboard event.
|
||||
private func onMoveFocus(notification: SwiftUI.Notification) {
|
||||
// Determine our desired direction
|
||||
guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return }
|
||||
guard let direction = directionAny as? SplitFocusDirection else { return }
|
||||
|
||||
// Find the next surface to move to. In most cases this should be
|
||||
// finding the neighbor in provided direction, and focus it. When
|
||||
// the neighbor cannot be found based on next or previous direction,
|
||||
// this would instead search for first or last leaf and focus it
|
||||
// instead, giving the wrap around effect.
|
||||
// When other directions are provided, this can be nil, and early
|
||||
// returned.
|
||||
guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction)
|
||||
?? node?.firstOrLast(direction)?.surface else { return }
|
||||
|
||||
Ghostty.moveFocus(
|
||||
to: nextSurface
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle a resize event.
|
||||
private func onResize(notification: SwiftUI.Notification) {
|
||||
// If this leaf is not part of a split then there is nothing to do
|
||||
guard let parent = leaf.parent else { return }
|
||||
|
||||
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
|
||||
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
|
||||
|
||||
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
|
||||
guard let amount = amountAny as? UInt16 else { return }
|
||||
|
||||
parent.resize(direction: direction, amount: amount)
|
||||
}
|
||||
}
|
||||
|
||||
/// This represents a split view that is in the horizontal or vertical split state.
|
||||
private struct TerminalSplitContainer: View {
|
||||
@EnvironmentObject var ghostty: Ghostty.App
|
||||
|
||||
let neighbors: SplitNode.Neighbors
|
||||
@Binding var node: SplitNode?
|
||||
@StateObject var container: SplitNode.Container
|
||||
|
||||
var body: some View {
|
||||
SplitView(
|
||||
container.direction,
|
||||
$container.split,
|
||||
dividerColor: ghostty.config.splitDividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
resizePublisher: container.resizeEvent,
|
||||
left: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableTopLeft(),
|
||||
neighbors: neighbors.update([
|
||||
neighborKey: container.bottomRight,
|
||||
\.next: container.bottomRight,
|
||||
])
|
||||
)
|
||||
}, right: {
|
||||
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
|
||||
|
||||
TerminalSplitNested(
|
||||
node: closeableBottomRight(),
|
||||
neighbors: neighbors.update([
|
||||
neighborKey: container.topLeft,
|
||||
\.previous: container.topLeft,
|
||||
])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private func closeableTopLeft() -> Binding<SplitNode?> {
|
||||
return .init(get: {
|
||||
container.topLeft
|
||||
}, set: { newValue in
|
||||
if let newValue {
|
||||
container.topLeft = newValue
|
||||
return
|
||||
}
|
||||
|
||||
// Closing
|
||||
container.topLeft.close()
|
||||
node = container.bottomRight
|
||||
|
||||
switch (node) {
|
||||
case .leaf(let l):
|
||||
l.parent = container.parent
|
||||
case .split(let c):
|
||||
c.parent = container.parent
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(
|
||||
to: container.bottomRight.preferredFocus(),
|
||||
from: container.topLeft.preferredFocus()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func closeableBottomRight() -> Binding<SplitNode?> {
|
||||
return .init(get: {
|
||||
container.bottomRight
|
||||
}, set: { newValue in
|
||||
if let newValue {
|
||||
container.bottomRight = newValue
|
||||
return
|
||||
}
|
||||
|
||||
// Closing
|
||||
container.bottomRight.close()
|
||||
node = container.topLeft
|
||||
|
||||
switch (node) {
|
||||
case .leaf(let l):
|
||||
l.parent = container.parent
|
||||
case .split(let c):
|
||||
c.parent = container.parent
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(
|
||||
to: container.topLeft.preferredFocus(),
|
||||
from: container.bottomRight.preferredFocus()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
|
||||
/// requires there be a binding to the parent node.
|
||||
private struct TerminalSplitNested: View {
|
||||
@Binding var node: SplitNode?
|
||||
let neighbors: SplitNode.Neighbors
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch (node) {
|
||||
case nil:
|
||||
Color(.clear)
|
||||
|
||||
case .leaf(let leaf):
|
||||
TerminalSplitLeaf(
|
||||
leaf: leaf,
|
||||
neighbors: neighbors,
|
||||
node: $node
|
||||
)
|
||||
|
||||
case .split(let container):
|
||||
TerminalSplitContainer(
|
||||
neighbors: neighbors,
|
||||
node: $node,
|
||||
container: container
|
||||
)
|
||||
}
|
||||
}
|
||||
.id(node)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
|
||||
/// that should have it.
|
||||
static func moveFocus(
|
||||
to: SurfaceView,
|
||||
from: SurfaceView? = nil,
|
||||
delay: TimeInterval? = nil
|
||||
) {
|
||||
// The whole delay machinery is a bit of a hack to work around a
|
||||
// situation where the window is destroyed and the surface view
|
||||
// will never be attached to a window. Realistically, we should
|
||||
// handle this upstream but we also don't want this function to be
|
||||
// a source of infinite loops.
|
||||
|
||||
// Our max delay before we give up
|
||||
let maxDelay: TimeInterval = 0.5
|
||||
guard (delay ?? 0) < maxDelay else { return }
|
||||
|
||||
// We start at a 50 millisecond delay and do a doubling backoff
|
||||
let nextDelay: TimeInterval = if let delay {
|
||||
delay * 2
|
||||
} else {
|
||||
// 100 milliseconds
|
||||
0.05
|
||||
}
|
||||
|
||||
let work: DispatchWorkItem = .init {
|
||||
// If the callback runs before the surface is attached to a view
|
||||
// then the window will be nil. We just reschedule in that case.
|
||||
guard let window = to.window else {
|
||||
moveFocus(to: to, from: from, delay: nextDelay)
|
||||
return
|
||||
}
|
||||
|
||||
// If we had a previously focused node and its not where we're sending
|
||||
// focus, make sure that we explicitly tell it to lose focus. In theory
|
||||
// we should NOT have to do this but the focus callback isn't getting
|
||||
// called for some reason.
|
||||
if let from = from {
|
||||
_ = from.resignFirstResponder()
|
||||
}
|
||||
|
||||
window.makeFirstResponder(to)
|
||||
}
|
||||
|
||||
let queue = DispatchQueue.main
|
||||
if let delay {
|
||||
queue.asyncAfter(deadline: .now() + delay, execute: work)
|
||||
} else {
|
||||
queue.async(execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||