mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Merge remote-tracking branch 'upstream/main' into grapheme-width-changes
This commit is contained in:
75
.agents/commands/review-branch
Executable file
75
.agents/commands/review-branch
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env nu
|
||||
|
||||
# A command to review the changes made in the current Git branch.
|
||||
#
|
||||
# IMPORTANT: This command is prompted to NOT write any code and to ONLY
|
||||
# produce a review summary. You should still be vigilant when running this
|
||||
# but that is the expected behavior.
|
||||
#
|
||||
# The optional `<issue>` parameter can be an issue number, PR number,
|
||||
# or a full GitHub URL to provide additional context.
|
||||
def main [
|
||||
issue?: any, # Optional GitHub issue/PR number or URL for context
|
||||
] {
|
||||
let issueContext = if $issue != null {
|
||||
let data = gh issue view $issue --json author,title,number,body,comments | from json
|
||||
let comments = if ($data.comments? != null) {
|
||||
$data.comments | each { |comment|
|
||||
let author = if ($comment.author?.login? != null) { $comment.author.login } else { "unknown" }
|
||||
$"
|
||||
### Comment by ($author)
|
||||
($comment.body)
|
||||
" | str trim
|
||||
} | str join "\n\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
$"
|
||||
## Source Issue: ($data.title) \(#($data.number)\)
|
||||
|
||||
### Description
|
||||
($data.body)
|
||||
|
||||
### Comments
|
||||
($comments)
|
||||
"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
$"
|
||||
# Branch Review
|
||||
|
||||
Inspect the changes made in this Git branch. Identify any possible issues
|
||||
and suggest improvements. Do not write code. Explain the problems clearly
|
||||
and propose a brief plan for addressing them.
|
||||
($issueContext)
|
||||
## Your Tasks
|
||||
|
||||
You are an experienced software developer with expertise in code review.
|
||||
|
||||
Review the change history between the current branch and its
|
||||
base branch. Analyze all relevant code for possible issues, including but
|
||||
not limited to:
|
||||
|
||||
- Code quality and readability
|
||||
- Code style that matches or mimics the rest of the codebase
|
||||
- Potential bugs or logical errors
|
||||
- Edge cases that may not be handled
|
||||
- Performance considerations
|
||||
- Security vulnerabilities
|
||||
- Backwards compatibility \(if applicable\)
|
||||
- Test coverage and effectiveness
|
||||
|
||||
For test coverage, consider if the changes are in an area of the codebase
|
||||
that is testable. If so, check if there are appropriate tests added or
|
||||
modified. Consider if the code itself should be modified to be more
|
||||
testable.
|
||||
|
||||
Think deeply about the implications of the changes here and proposed.
|
||||
Consult the oracle if you have access to it.
|
||||
|
||||
**ONLY CREATE A SUMMARY. DO NOT WRITE ANY CODE.**
|
||||
" | str trim
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{sh,bash,elv}]
|
||||
[*.{sh,bash,elv,nu}]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -4,11 +4,11 @@ build.zig.zon.json linguist-generated=true
|
||||
vendor/** linguist-vendored
|
||||
website/** linguist-documentation
|
||||
pkg/breakpad/vendor/** linguist-vendored
|
||||
pkg/cimgui/vendor/** linguist-vendored
|
||||
pkg/glfw/wayland-headers/** linguist-vendored
|
||||
pkg/libintl/config.h linguist-generated=true
|
||||
pkg/libintl/libintl.h linguist-generated=true
|
||||
pkg/simdutf/vendor/** linguist-vendored
|
||||
src/font/nerd_font_attributes.zig linguist-generated=true
|
||||
src/font/nerd_font_codepoint_tables.py linguist-generated=true
|
||||
src/font/res/** linguist-vendored
|
||||
src/terminal/res/** linguist-vendored
|
||||
|
||||
50
.github/workflows/flatpak.yml
vendored
Normal file
50
.github/workflows/flatpak.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source-run-id:
|
||||
description: run id of the workflow that generated the artifact
|
||||
required: true
|
||||
type: string
|
||||
source-artifact-id:
|
||||
description: source tarball built during build-dist
|
||||
required: true
|
||||
type: string
|
||||
|
||||
name: Flatpak
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
name: "Flatpak"
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
|
||||
options: --privileged
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- arch: x86_64
|
||||
runner: namespace-profile-ghostty-md
|
||||
- arch: aarch64
|
||||
runner: namespace-profile-ghostty-md-arm64
|
||||
runs-on: ${{ matrix.variant.runner }}
|
||||
steps:
|
||||
- name: Download Source Tarball Artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
run-id: ${{ inputs.source-run-id }}
|
||||
artifact-ids: ${{ inputs.source-artifact-id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Extract tarball
|
||||
run: |
|
||||
mkdir dist
|
||||
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
|
||||
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6
|
||||
with:
|
||||
bundle: com.mitchellh.ghostty
|
||||
manifest-path: dist/flatpak/com.mitchellh.ghostty.yml
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
arch: ${{ matrix.variant.arch }}
|
||||
verbose: true
|
||||
4
.github/workflows/milestone.yml
vendored
4
.github/workflows/milestone.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Milestone Update
|
||||
steps:
|
||||
- name: Set Milestone for PR
|
||||
uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0
|
||||
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
|
||||
if: github.event.pull_request.merged == true
|
||||
with:
|
||||
action: bind-pr # `bind-pr` is the default action
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
# Bind milestone to closed issue that has a merged PR fix
|
||||
- name: Set Milestone for Issue
|
||||
uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0
|
||||
uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1
|
||||
if: github.event.issue.state == 'closed'
|
||||
with:
|
||||
action: bind-issue
|
||||
|
||||
6
.github/workflows/nix.yml
vendored
6
.github/workflows/nix.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
@@ -47,10 +47,10 @@ jobs:
|
||||
/nix
|
||||
/zig
|
||||
- name: Setup Nix
|
||||
uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
2
.github/workflows/publish-tag.yml
vendored
2
.github/workflows/publish-tag.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
mkdir blob
|
||||
mv appcast.xml blob/appcast.xml
|
||||
- name: Upload Appcast to R2
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
|
||||
with:
|
||||
r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
|
||||
r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}
|
||||
|
||||
32
.github/workflows/release-tag.yml
vendored
32
.github/workflows/release-tag.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Important so that build number generation works
|
||||
fetch-depth: 0
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -89,11 +89,11 @@ jobs:
|
||||
/nix
|
||||
/zig
|
||||
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: source-tarball
|
||||
path: |-
|
||||
@@ -132,18 +132,18 @@ jobs:
|
||||
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: macos
|
||||
path: |-
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
|
||||
- name: Download macOS Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: macos
|
||||
|
||||
@@ -306,10 +306,10 @@ jobs:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download macOS Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: macos
|
||||
|
||||
@@ -340,7 +340,7 @@ jobs:
|
||||
mv appcast_new.xml appcast.xml
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: sparkle
|
||||
path: |-
|
||||
@@ -357,17 +357,17 @@ jobs:
|
||||
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
|
||||
steps:
|
||||
- name: Download macOS Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: macos
|
||||
|
||||
- name: Download Sparkle Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: sparkle
|
||||
|
||||
- name: Download Source Tarball Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: source-tarball
|
||||
|
||||
|
||||
38
.github/workflows/release-tip.yml
vendored
38
.github/workflows/release-tip.yml
vendored
@@ -29,14 +29,14 @@ jobs:
|
||||
commit: ${{ steps.extract_build_info.outputs.commit }}
|
||||
commit_long: ${{ steps.extract_build_info.outputs.commit_long }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Important so that build number generation works
|
||||
fetch-depth: 0
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
needs: [setup, build-macos]
|
||||
if: needs.setup.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Tip Tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
env:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install sentry-cli
|
||||
run: |
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
env:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install sentry-cli
|
||||
run: |
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
env:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install sentry-cli
|
||||
run: |
|
||||
@@ -159,17 +159,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Important so that build number generation works
|
||||
fetch-depth: 0
|
||||
@@ -226,13 +226,13 @@ jobs:
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Important so that build number generation works
|
||||
fetch-depth: 0
|
||||
@@ -460,13 +460,13 @@ jobs:
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -635,7 +635,7 @@ jobs:
|
||||
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Important so that build number generation works
|
||||
fetch-depth: 0
|
||||
@@ -644,13 +644,13 @@ jobs:
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
|
||||
2
.github/workflows/snap.yml
vendored
2
.github/workflows/snap.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Download Source Tarball Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
run-id: ${{ inputs.source-run-id }}
|
||||
artifact-ids: ${{ inputs.source-artifact-id }}
|
||||
|
||||
239
.github/workflows/test.yml
vendored
239
.github/workflows/test.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- build-linux-libghostty
|
||||
- build-nix
|
||||
- build-macos
|
||||
- build-macos-matrix
|
||||
- build-macos-freetype
|
||||
- build-snap
|
||||
- build-windows
|
||||
- test
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- test-debian-13
|
||||
- valgrind
|
||||
- zig-fmt
|
||||
- flatpak
|
||||
|
||||
steps:
|
||||
- id: status
|
||||
name: Determine status
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -84,10 +84,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -127,10 +127,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -160,10 +160,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -194,10 +194,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -217,6 +217,7 @@ jobs:
|
||||
x86_64-macos,
|
||||
aarch64-linux,
|
||||
x86_64-linux,
|
||||
x86_64-linux-musl,
|
||||
x86_64-windows,
|
||||
wasm32-freestanding,
|
||||
]
|
||||
@@ -227,7 +228,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -237,10 +238,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -263,7 +264,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -273,10 +274,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -292,7 +293,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -302,10 +303,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -325,7 +326,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -335,10 +336,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -371,7 +372,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -381,10 +382,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -397,7 +398,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: source-tarball
|
||||
path: |-
|
||||
@@ -409,7 +410,7 @@ jobs:
|
||||
needs: [build-dist, build-snap]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Trigger Snap workflow
|
||||
run: |
|
||||
@@ -421,24 +422,42 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
trigger-flatpak:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
needs: [build-dist, build-flatpak]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Trigger Flatpak workflow
|
||||
run: |
|
||||
gh workflow run \
|
||||
flatpak.yml \
|
||||
--ref ${{ github.ref_name || 'main' }} \
|
||||
--field source-run-id=${{ github.run_id }} \
|
||||
--field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-macos:
|
||||
runs-on: namespace-profile-ghostty-macos-tahoe
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -464,24 +483,24 @@ jobs:
|
||||
cd macos
|
||||
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
||||
|
||||
build-macos-matrix:
|
||||
build-macos-freetype:
|
||||
runs-on: namespace-profile-ghostty-macos-tahoe
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -493,18 +512,10 @@ jobs:
|
||||
- name: Test All
|
||||
run: |
|
||||
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype
|
||||
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext
|
||||
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype
|
||||
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz
|
||||
nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape
|
||||
|
||||
- name: Build All
|
||||
run: |
|
||||
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype
|
||||
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext
|
||||
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype
|
||||
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz
|
||||
nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2022
|
||||
@@ -514,7 +525,7 @@ jobs:
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# This could be from a script if we wanted to but inlining here for now
|
||||
# in one place.
|
||||
@@ -585,7 +596,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get required Zig version
|
||||
id: zig
|
||||
@@ -600,10 +611,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -632,7 +643,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -642,10 +653,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -680,7 +691,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -690,10 +701,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -715,7 +726,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -725,10 +736,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -742,19 +753,19 @@ jobs:
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -779,7 +790,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -789,10 +800,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -809,17 +820,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -833,21 +844,23 @@ jobs:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -855,6 +868,8 @@ jobs:
|
||||
useDaemon: false # sometimes fails on short jobs
|
||||
- name: pinact check
|
||||
run: nix develop -c pinact run --check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
prettier:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
@@ -864,17 +879,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -891,17 +906,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -918,17 +933,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -945,17 +960,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -967,8 +982,6 @@ jobs:
|
||||
--check-sourced \
|
||||
--color=always \
|
||||
--severity=warning \
|
||||
--shell=bash \
|
||||
--external-sources \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
|
||||
translations:
|
||||
@@ -979,17 +992,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -1006,17 +1019,17 @@ jobs:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
with:
|
||||
path: |
|
||||
/nix
|
||||
/zig
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -1040,7 +1053,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -1050,10 +1063,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -1072,10 +1085,10 @@ jobs:
|
||||
uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10
|
||||
|
||||
- name: Configure Namespace powered Buildx
|
||||
uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20
|
||||
uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21
|
||||
|
||||
- name: Download Source Tarball Artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: source-tarball
|
||||
|
||||
@@ -1092,32 +1105,6 @@ jobs:
|
||||
build-args: |
|
||||
DISTRO_VERSION=13
|
||||
|
||||
flatpak:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
name: "Flatpak"
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47
|
||||
options: --privileged
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- arch: x86_64
|
||||
runner: namespace-profile-ghostty-md
|
||||
- arch: aarch64
|
||||
runner: namespace-profile-ghostty-md-arm64
|
||||
runs-on: ${{ matrix.variant.runner }}
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6
|
||||
with:
|
||||
bundle: com.mitchellh.ghostty
|
||||
manifest-path: flatpak/com.mitchellh.ghostty.yml
|
||||
cache-key: flatpak-builder-${{ github.sha }}
|
||||
arch: ${{ matrix.variant.arch }}
|
||||
verbose: true
|
||||
|
||||
valgrind:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
runs-on: namespace-profile-ghostty-lg
|
||||
@@ -1128,7 +1115,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
|
||||
@@ -1138,10 +1125,10 @@ jobs:
|
||||
/zig
|
||||
|
||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
@@ -1167,7 +1154,7 @@ jobs:
|
||||
# timeout-minutes: 10
|
||||
# steps:
|
||||
# - name: Checkout Ghostty
|
||||
# uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
#
|
||||
# - name: Start SSH
|
||||
# run: |
|
||||
|
||||
33
.github/workflows/update-colorschemes.yml
vendored
33
.github/workflows/update-colorschemes.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -29,24 +29,41 @@ jobs:
|
||||
/zig
|
||||
|
||||
- name: Setup Nix
|
||||
uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
|
||||
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
|
||||
with:
|
||||
name: ghostty
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Run zig fetch
|
||||
id: zig_fetch
|
||||
- name: Download colorschemes
|
||||
id: download
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Get the latest release from iTerm2-Color-Schemes
|
||||
RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest)
|
||||
TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name')
|
||||
nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz"
|
||||
FILENAME="ghostty-themes-${TAG_NAME}.tgz"
|
||||
mkdir -p upload
|
||||
curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz"
|
||||
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "filename=$FILENAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to R2
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
|
||||
with:
|
||||
r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }}
|
||||
r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }}
|
||||
r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }}
|
||||
r2-bucket: ghostty-deps
|
||||
source-dir: upload
|
||||
destination-dir: ./
|
||||
|
||||
- name: Run zig fetch
|
||||
run: |
|
||||
nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}"
|
||||
|
||||
- name: Update zig cache hash
|
||||
run: |
|
||||
@@ -62,7 +79,7 @@ jobs:
|
||||
run: nix build .#ghostty
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
title: Update iTerm2 colorschemes
|
||||
base: main
|
||||
@@ -75,5 +92,5 @@ jobs:
|
||||
build.zig.zon.json
|
||||
flatpak/zig-packages.json
|
||||
body: |
|
||||
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }}
|
||||
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }}
|
||||
labels: dependencies
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ zig-cache/
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
/result*
|
||||
/.nixos-test-history
|
||||
example/*.wasm
|
||||
test/ghostty
|
||||
test/cases/**/*.actual.png
|
||||
|
||||
8
.shellcheckrc
Normal file
8
.shellcheckrc
Normal file
@@ -0,0 +1,8 @@
|
||||
# ShellCheck <https://www.shellcheck.net/>
|
||||
# https://github.com/koalaman/shellcheck/wiki/Directive#shellcheckrc-file
|
||||
|
||||
# Allow opening any 'source'd file, even if not specified as input
|
||||
external-sources=true
|
||||
|
||||
# Assume bash by default
|
||||
shell=bash
|
||||
@@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/).
|
||||
|
||||
- Do not use `xcodebuild`
|
||||
- Use `zig build` to build the macOS app and any shared Zig code
|
||||
- Use `zig build run` to build and run the macOS app
|
||||
- Run Xcode tests using `zig build test`
|
||||
|
||||
69
AI_POLICY.md
Normal file
69
AI_POLICY.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# AI Usage Policy
|
||||
|
||||
The Ghostty project has strict rules for AI usage:
|
||||
|
||||
- **All AI usage in any form must be disclosed.** You must state
|
||||
the tool you used (e.g. Claude Code, Cursor, Amp) along with
|
||||
the extent that the work was AI-assisted.
|
||||
|
||||
- **Pull requests created in any way by AI can only be for accepted issues.**
|
||||
Drive-by pull requests that do not reference an accepted issue will be
|
||||
closed. If AI isn't disclosed but a maintainer suspects its use, the
|
||||
PR will be closed. If you want to share code for a non-accepted issue,
|
||||
open a discussion or attach it to an existing discussion.
|
||||
|
||||
- **Pull requests created by AI must have been fully verified with
|
||||
human use.** AI must not create hypothetically correct code that
|
||||
hasn't been tested. Importantly, you must not allow AI to write
|
||||
code for platforms or environments you don't have access to manually
|
||||
test on.
|
||||
|
||||
- **Issues and discussions can use AI assistance but must have a full
|
||||
human-in-the-loop.** This means that any content generated with AI
|
||||
must have been reviewed _and edited_ by a human before submission.
|
||||
AI is very good at being overly verbose and including noise that
|
||||
distracts from the main point. Humans must do their research and
|
||||
trim this down.
|
||||
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
|
||||
Text and code are the only acceptable AI-generated content, per the
|
||||
other rules in this policy.
|
||||
|
||||
- **Bad AI drivers will be banned and ridiculed in public.** You've
|
||||
been warned. We love to help junior developers learn and grow, but
|
||||
if you're interested in that then don't use AI, and we'll help you.
|
||||
I'm sorry that bad AI drivers have ruined this for you.
|
||||
|
||||
These rules apply only to outside contributions to Ghostty. Maintainers
|
||||
are exempt from these rules and may use AI tools at their discretion;
|
||||
they've proven themselves trustworthy to apply good judgment.
|
||||
|
||||
## There are Humans Here
|
||||
|
||||
Please remember that Ghostty is maintained by humans.
|
||||
|
||||
Every discussion, issue, and pull request is read and reviewed by
|
||||
humans (and sometimes machines, too). It is a boundary point at which
|
||||
people interact with each other and the work done. It is rude and
|
||||
disrespectful to approach this boundary with low-effort, unqualified
|
||||
work, since it puts the burden of validation on the maintainer.
|
||||
|
||||
In a perfect world, AI would produce high-quality, accurate work
|
||||
every time. But today, that reality depends on the driver of the AI.
|
||||
And today, most drivers of AI are just not good enough. So, until either
|
||||
the people get better, the AI gets better, or both, we have to have
|
||||
strict rules to protect maintainers.
|
||||
|
||||
## AI is Welcome Here
|
||||
|
||||
Ghostty is written with plenty of AI assistance, and many maintainers embrace
|
||||
AI tools as a productive tool in their workflow. As a project, we welcome
|
||||
AI as a tool!
|
||||
|
||||
**Our reason for the strict AI policy is not due to an anti-AI stance**, but
|
||||
instead due to the number of highly unqualified people using AI. It's the
|
||||
people, not the tools, that are the problem.
|
||||
|
||||
I include this section to be transparent about the project's usage about
|
||||
AI for people who may disagree with it, and to address the misconception
|
||||
that this policy is anti-AI in nature.
|
||||
320
CONTRIBUTING.md
320
CONTRIBUTING.md
@@ -13,61 +13,10 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
|
||||
> time to fixing bugs, maintaining features, and reviewing code, I do kindly
|
||||
> ask you spend a few minutes reading this document. Thank you. ❤️
|
||||
|
||||
## AI Assistance Notice
|
||||
## AI Usage
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> If you are using **any kind of AI assistance** to contribute to Ghostty,
|
||||
> it must be disclosed in the pull request.
|
||||
|
||||
If you are using any kind of AI assistance while contributing to Ghostty,
|
||||
**this must be disclosed in the pull request**, along with the extent to
|
||||
which AI assistance was used (e.g. docs only vs. code generation).
|
||||
As a small exception, trivial tab-completion doesn't need to be disclosed,
|
||||
so long as it is limited to single keywords or short phrases.
|
||||
|
||||
The submitter must have also tested the pull request on all impacted
|
||||
platforms, and it's **highly discouraged** to code for an unfamiliar platform
|
||||
with AI assistance alone: if you only have a macOS machine, do **not** ask AI
|
||||
to write the equivalent GTK code, and vice versa — someone else with more
|
||||
expertise will eventually get to it and do it for you.
|
||||
|
||||
Even though using AI to generate responses on a PR is allowed when properly
|
||||
disclosed, **we do not encourage you to do so**. Often, the positive impact
|
||||
of genuine, responsive human interaction more than makes up for any language
|
||||
barrier. ❤️
|
||||
|
||||
An example disclosure:
|
||||
|
||||
> This PR was written primarily by Claude Code.
|
||||
|
||||
Or a more detailed disclosure:
|
||||
|
||||
> I consulted ChatGPT to understand the codebase but the solution
|
||||
> was fully authored manually by myself.
|
||||
|
||||
An example of a **problematic** disclosure (not having tested all platforms):
|
||||
|
||||
> I used Amp to code both macOS and GTK UIs, but I have not yet tested
|
||||
> the GTK UI as I don't have a Linux setup.
|
||||
|
||||
Failure to disclose this is first and foremost rude to the human operators
|
||||
on the other end of the pull request, but it also makes it difficult to
|
||||
determine how much scrutiny to apply to the contribution.
|
||||
|
||||
In a perfect world, AI assistance would produce equal or higher quality
|
||||
work than any human. That isn't the world we live in today, and in most cases
|
||||
it's generating slop. I say this despite being a fan of and using them
|
||||
successfully myself (with heavy supervision)!
|
||||
|
||||
When using AI assistance, we expect a fairly high level of accountability
|
||||
and responsibility from contributors, and expect them to understand the code
|
||||
that is produced and be able to answer critical questions about it. It
|
||||
isn't a maintainers job to review a PR so broken that it requires
|
||||
significant rework to be acceptable, and we **reserve the right to close
|
||||
these PRs without hesitation**.
|
||||
|
||||
Please be respectful to maintainers and disclose AI assistance.
|
||||
The Ghostty project has strict rules for AI usage. Please see
|
||||
the [AI Usage Policy](AI_POLICY.md). **This is very important.**
|
||||
|
||||
## Quick Guide
|
||||
|
||||
@@ -202,3 +151,266 @@ pull request will be accepted with a high degree of certainty.
|
||||
> **Pull requests are NOT a place to discuss feature design.** Please do
|
||||
> not open a WIP pull request to discuss a feature. Instead, use a discussion
|
||||
> and link to your branch.
|
||||
|
||||
# Developer Guide
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> **The remainder of this file is dedicated to developers actively
|
||||
> working on Ghostty.** If you're a user reporting an issue, you can
|
||||
> ignore the rest of this document.
|
||||
|
||||
## Including and Updating Translations
|
||||
|
||||
See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details.
|
||||
|
||||
## Checking for Memory Leaks
|
||||
|
||||
While Zig does an amazing job of finding and preventing memory leaks,
|
||||
Ghostty uses many third-party libraries that are written in C. Improper usage
|
||||
of those libraries or bugs in those libraries can cause memory leaks that
|
||||
Zig cannot detect by itself.
|
||||
|
||||
### On Linux
|
||||
|
||||
On Linux the recommended tool to check for memory leaks is Valgrind. The
|
||||
recommended way to run Valgrind is via `zig build`:
|
||||
|
||||
```sh
|
||||
zig build run-valgrind
|
||||
```
|
||||
|
||||
This builds a Ghostty executable with Valgrind support and runs Valgrind
|
||||
with the proper flags to ensure we're suppressing known false positives.
|
||||
|
||||
You can combine the same build args with `run-valgrind` that you can with
|
||||
`run`, such as specifying additional configurations after a trailing `--`.
|
||||
|
||||
## Input Stack Testing
|
||||
|
||||
The input stack is the part of the codebase that starts with a
|
||||
key event and ends with text encoding being sent to the pty (it
|
||||
does not include _rendering_ the text, which is part of the
|
||||
font or rendering stack).
|
||||
|
||||
If you modify any part of the input stack, you must manually verify
|
||||
all the following input cases work properly. We unfortunately do
|
||||
not automate this in any way, but if we can do that one day that'd
|
||||
save a LOT of grief and time.
|
||||
|
||||
Note: this list may not be exhaustive, I'm still working on it.
|
||||
|
||||
### Linux IME
|
||||
|
||||
IME (Input Method Editors) are a common source of bugs in the input stack,
|
||||
especially on Linux since there are multiple different IME systems
|
||||
interacting with different windowing systems and application frameworks
|
||||
all written by different organizations.
|
||||
|
||||
The following matrix should be tested to ensure that all IME input works
|
||||
properly:
|
||||
|
||||
1. Wayland, X11
|
||||
2. ibus, fcitx, none
|
||||
3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex
|
||||
4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors)
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> This is a **work in progress**. I'm still working on this list and it
|
||||
> is not complete. As I find more test cases, I will add them here.
|
||||
|
||||
#### Dead Key Input
|
||||
|
||||
Set your keyboard layout to "Spanish" (or another layout that uses dead keys).
|
||||
|
||||
1. Launch Ghostty
|
||||
2. Press `'`
|
||||
3. Press `a`
|
||||
4. Verify that `á` is displayed
|
||||
|
||||
Note that the dead key may or may not show a preedit state visually.
|
||||
For ibus and fcitx it does but for the "none" case it does not. Importantly,
|
||||
the text should be correct when it is sent to the pty.
|
||||
|
||||
We should also test canceling dead key input:
|
||||
|
||||
1. Launch Ghostty
|
||||
2. Press `'`
|
||||
3. Press escape
|
||||
4. Press `a`
|
||||
5. Verify that `a` is displayed (no diacritic)
|
||||
|
||||
#### CJK Input
|
||||
|
||||
Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The
|
||||
exact layout doesn't matter.
|
||||
|
||||
1. Launch Ghostty
|
||||
2. Press `Ctrl+Shift` to switch to "Hiragana"
|
||||
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
|
||||
4. Press `Enter`
|
||||
5. Verify that `こん` is displayed in the terminal.
|
||||
|
||||
We should also test switching input methods while preedit is active, which
|
||||
should commit the text:
|
||||
|
||||
1. Launch Ghostty
|
||||
2. Press `Ctrl+Shift` to switch to "Hiragana"
|
||||
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
|
||||
4. Press `Ctrl+Shift` to switch to another layout (any)
|
||||
5. Verify that `こん` is displayed in the terminal as committed text.
|
||||
|
||||
## Nix Virtual Machines
|
||||
|
||||
Several Nix virtual machine definitions are provided by the project for testing
|
||||
and developing Ghostty against multiple different Linux desktop environments.
|
||||
|
||||
Running these requires a working Nix installation, either Nix on your
|
||||
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
|
||||
requirements for macOS are detailed below.
|
||||
|
||||
VMs should only be run on your local desktop and then powered off when not in
|
||||
use, which will discard any changes to the VM.
|
||||
|
||||
The VM definitions provide minimal software "out of the box" but additional
|
||||
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
|
||||
|
||||
### Linux
|
||||
|
||||
1. Check out the Ghostty source and change to the directory.
|
||||
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
|
||||
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
|
||||
with `common` or `create`.
|
||||
3. The VM will build and then launch. Depending on the speed of your system, this
|
||||
can take a while, but eventually you should get a new VM window.
|
||||
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
|
||||
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
|
||||
writable by the VM user, so be careful!
|
||||
|
||||
### macOS
|
||||
|
||||
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
|
||||
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
|
||||
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
|
||||
blog post for more information about the Linux builder and how to tune the performance.
|
||||
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
|
||||
above to launch a VM.
|
||||
|
||||
### Custom VMs
|
||||
|
||||
To easily create a custom VM without modifying the Ghostty source, create a new
|
||||
directory, then create a file called `flake.nix` with the following text in the
|
||||
new directory.
|
||||
|
||||
```
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
|
||||
ghostty.url = "github:ghostty-org/ghostty";
|
||||
};
|
||||
outputs = {
|
||||
nixpkgs,
|
||||
ghostty,
|
||||
...
|
||||
}: {
|
||||
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
|
||||
nixpkgs = nixpkgs;
|
||||
system = "x86_64-linux";
|
||||
overlay = ghostty.overlays.releasefast;
|
||||
# module = ./configuration.nix # also works
|
||||
module = {pkgs, ...}: {
|
||||
environment.systemPackages = [
|
||||
pkgs.btop
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The custom VM can then be run with a command like this:
|
||||
|
||||
```
|
||||
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
|
||||
```
|
||||
|
||||
A file named `ghostty.qcow2` will be created that is used to persist any changes
|
||||
made in the VM. To "reset" the VM to default delete the file and it will be
|
||||
recreated the next time you run the VM.
|
||||
|
||||
### Contributing new VM definitions
|
||||
|
||||
#### VM Acceptance Criteria
|
||||
|
||||
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
|
||||
|
||||
1. They should be different enough from existing VM definitions that they represent a distinct
|
||||
user (and developer) experience.
|
||||
2. There's a significant Ghostty user population that uses a similar environment.
|
||||
3. The VMs can be built using only packages from the current stable NixOS release.
|
||||
|
||||
#### VM Definition Criteria
|
||||
|
||||
1. VMs should be as minimal as possible so that they build and launch quickly.
|
||||
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
|
||||
2. VMs should not expose any services to the network, or run any remote access
|
||||
software like SSH daemons, VNC or RDP.
|
||||
3. VMs should auto-login using the "ghostty" user.
|
||||
|
||||
## Nix VM Integration Tests
|
||||
|
||||
Several Nix VM tests are provided by the project for testing Ghostty in a "live"
|
||||
environment rather than just unit tests.
|
||||
|
||||
Running these requires a working Nix installation, either Nix on your
|
||||
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
|
||||
requirements for macOS are detailed below.
|
||||
|
||||
### Linux
|
||||
|
||||
1. Check out the Ghostty source and change to the directory.
|
||||
2. Run `nix run .#check.<system>.<test-name>.driver`. `<system>` should be
|
||||
`x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux
|
||||
VM, not a macOS one). `<test-name>` should be one of the tests defined in
|
||||
`nix/tests.nix`. The test will build and then launch. Depending on the speed
|
||||
of your system, this can take a while. Eventually though the test should
|
||||
complete. Hopefully successfully, but if not error messages should be printed
|
||||
out that can be used to diagnose the issue.
|
||||
3. To run _all_ of the tests, run `nix flake check`.
|
||||
|
||||
### macOS
|
||||
|
||||
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
|
||||
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
|
||||
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
|
||||
blog post for more information about the Linux builder and how to tune the performance.
|
||||
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
|
||||
above to launch a test.
|
||||
|
||||
### Interactively Running Test VMs
|
||||
|
||||
To run a test interactively, run `nix run
|
||||
.#check.<system>.<test-name>.driverInteractive`. This will load a Python console
|
||||
that can be used to manage the test VMs. In this console run `start_all()` to
|
||||
start the VM(s). The VMs should boot up and a window should appear showing the
|
||||
VM's console.
|
||||
|
||||
For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos)
|
||||
|
||||
### SSH Access to Test VMs
|
||||
|
||||
Some test VMs are configured to allow outside SSH access for debugging. To
|
||||
access the VM, use a command like the following:
|
||||
|
||||
```
|
||||
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1
|
||||
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1
|
||||
```
|
||||
|
||||
The SSH options are important because the SSH host keys will be regenerated
|
||||
every time the test is started. Without them, your personal SSH known hosts file
|
||||
will become difficult to manage. The port that is needed to access the VM may
|
||||
change depending on the test.
|
||||
|
||||
None of the users in the VM have passwords so do not expose these VMs to the Internet.
|
||||
|
||||
52
HACKING.md
52
HACKING.md
@@ -93,6 +93,36 @@ produced.
|
||||
> may ask you to fix it and close the issue. It isn't a maintainers job to
|
||||
> review a PR so broken that it requires significant rework to be acceptable.
|
||||
|
||||
## Logging
|
||||
|
||||
Ghostty can write logs to a number of destinations. On all platforms, logging to
|
||||
`stderr` is available. Depending on the platform and how Ghostty was launched,
|
||||
logs sent to `stderr` may be stored by the system and made available for later
|
||||
retrieval.
|
||||
|
||||
On Linux if Ghostty is launched by the default `systemd` user service, you can use
|
||||
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
|
||||
|
||||
On macOS logging to the macOS unified log is available and enabled by default.
|
||||
Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
|
||||
|
||||
Ghostty's logging can be configured in two ways. The first is by what
|
||||
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
|
||||
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
|
||||
any other optimization the debug logs will not be output to `stderr`.
|
||||
|
||||
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
|
||||
to control which destinations receive logs. Ghostty currently defines two
|
||||
destinations:
|
||||
|
||||
- `stderr` - logging to `stderr`.
|
||||
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
|
||||
|
||||
Combine values with a comma to enable multiple destinations. Prefix a
|
||||
destination with `no-` to disable it. Enabling and disabling destinations
|
||||
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
|
||||
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
|
||||
|
||||
## Linting
|
||||
|
||||
### Prettier
|
||||
@@ -134,6 +164,28 @@ alejandra .
|
||||
|
||||
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
|
||||
|
||||
### ShellCheck
|
||||
|
||||
Bash scripts are checked with [ShellCheck](https://www.shellcheck.net/) in CI.
|
||||
|
||||
Nix users can use the following command to run ShellCheck over all of our scripts:
|
||||
|
||||
```
|
||||
nix develop -c shellcheck \
|
||||
--check-sourced \
|
||||
--severity=warning \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
```
|
||||
|
||||
Non-Nix users can [install ShellCheck](https://github.com/koalaman/shellcheck#user-content-installing) and then run:
|
||||
|
||||
```
|
||||
shellcheck \
|
||||
--check-sourced \
|
||||
--severity=warning \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
```
|
||||
|
||||
### Updating the Zig Cache Fixed-Output Derivation Hash
|
||||
|
||||
The Nix package depends on a [fixed-output
|
||||
|
||||
@@ -2,6 +2,7 @@ const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const builtin = @import("builtin");
|
||||
const buildpkg = @import("src/build/main.zig");
|
||||
|
||||
const appVersion = @import("build.zig.zon").version;
|
||||
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
},
|
||||
.vaxis = .{
|
||||
// rockorager/libvaxis
|
||||
.url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
.url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
.hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
|
||||
.lazy = true,
|
||||
},
|
||||
.z2d = .{
|
||||
// vancluever/z2d
|
||||
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
|
||||
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
|
||||
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||
.lazy = true,
|
||||
},
|
||||
.zig_objc = .{
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
.uucode = .{
|
||||
// jacobsandlund/uucode
|
||||
.url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
.url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
.hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
|
||||
},
|
||||
.zig_wayland = .{
|
||||
@@ -50,20 +50,20 @@
|
||||
},
|
||||
.zf = .{
|
||||
// natecraddock/zf
|
||||
.url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
.url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
.hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
|
||||
.lazy = true,
|
||||
},
|
||||
.gobject = .{
|
||||
// https://github.com/jcollie/ghostty-gobject based on zig_gobject
|
||||
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
|
||||
// Temporary until we generate them at build time automatically.
|
||||
.url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
|
||||
.hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
|
||||
.url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
|
||||
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
// C libs
|
||||
.cimgui = .{ .path = "./pkg/cimgui", .lazy = true },
|
||||
.dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true },
|
||||
.fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true },
|
||||
.freetype = .{ .path = "./pkg/freetype", .lazy = true },
|
||||
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true },
|
||||
@@ -116,8 +116,8 @@
|
||||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
.hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
|
||||
.lazy = true,
|
||||
},
|
||||
},
|
||||
|
||||
124
build.zig.zon.bak
Normal file
124
build.zig.zon.bak
Normal file
@@ -0,0 +1,124 @@
|
||||
.{
|
||||
.name = .ghostty,
|
||||
.version = "1.3.0-dev",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x64407a2a0b4147e5,
|
||||
.minimum_zig_version = "0.15.2",
|
||||
.dependencies = .{
|
||||
// Zig libs
|
||||
|
||||
.libxev = .{
|
||||
// mitchellh/libxev
|
||||
.url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz",
|
||||
.hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs",
|
||||
.lazy = true,
|
||||
},
|
||||
.vaxis = .{
|
||||
// rockorager/libvaxis
|
||||
.url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
.hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
|
||||
.lazy = true,
|
||||
},
|
||||
.z2d = .{
|
||||
// vancluever/z2d
|
||||
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
|
||||
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
|
||||
.lazy = true,
|
||||
},
|
||||
.zig_objc = .{
|
||||
// mitchellh/zig-objc
|
||||
.url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz",
|
||||
.hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK",
|
||||
.lazy = true,
|
||||
},
|
||||
.zig_js = .{
|
||||
// mitchellh/zig-js
|
||||
.url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz",
|
||||
.hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi",
|
||||
.lazy = true,
|
||||
},
|
||||
.uucode = .{
|
||||
// jacobsandlund/uucode
|
||||
.url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
.hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
|
||||
},
|
||||
.zig_wayland = .{
|
||||
// codeberg ifreund/zig-wayland
|
||||
.url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz",
|
||||
.hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe",
|
||||
.lazy = true,
|
||||
},
|
||||
.zf = .{
|
||||
// natecraddock/zf
|
||||
.url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
.hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
|
||||
.lazy = true,
|
||||
},
|
||||
.gobject = .{
|
||||
// https://github.com/ghostty-org/zig-gobject based on zig_gobject
|
||||
// Temporary until we generate them at build time automatically.
|
||||
.url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst",
|
||||
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
// C libs
|
||||
.cimgui = .{ .path = "./pkg/cimgui", .lazy = true },
|
||||
.fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true },
|
||||
.freetype = .{ .path = "./pkg/freetype", .lazy = true },
|
||||
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true },
|
||||
.harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true },
|
||||
.highway = .{ .path = "./pkg/highway", .lazy = true },
|
||||
.libintl = .{ .path = "./pkg/libintl", .lazy = true },
|
||||
.libpng = .{ .path = "./pkg/libpng", .lazy = true },
|
||||
.macos = .{ .path = "./pkg/macos", .lazy = true },
|
||||
.oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true },
|
||||
.opengl = .{ .path = "./pkg/opengl", .lazy = true },
|
||||
.sentry = .{ .path = "./pkg/sentry", .lazy = true },
|
||||
.simdutf = .{ .path = "./pkg/simdutf", .lazy = true },
|
||||
.utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true },
|
||||
.wuffs = .{ .path = "./pkg/wuffs", .lazy = true },
|
||||
.zlib = .{ .path = "./pkg/zlib", .lazy = true },
|
||||
|
||||
// Shader translation
|
||||
.glslang = .{ .path = "./pkg/glslang", .lazy = true },
|
||||
.spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true },
|
||||
|
||||
// Wayland
|
||||
.wayland = .{
|
||||
.url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz",
|
||||
.hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t",
|
||||
.lazy = true,
|
||||
},
|
||||
.wayland_protocols = .{
|
||||
.url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz",
|
||||
.hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S",
|
||||
.lazy = true,
|
||||
},
|
||||
.plasma_wayland_protocols = .{
|
||||
.url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz",
|
||||
.hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
// Fonts
|
||||
.jetbrains_mono = .{
|
||||
.url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz",
|
||||
.hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x",
|
||||
.lazy = true,
|
||||
},
|
||||
.nerd_fonts_symbols_only = .{
|
||||
.url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz",
|
||||
.hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
.lazy = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
35
build.zig.zon.json
generated
35
build.zig.zon.json
generated
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": {
|
||||
"name": "bindings",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM="
|
||||
},
|
||||
"N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ": {
|
||||
"name": "breakpad",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -24,10 +29,10 @@
|
||||
"url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
|
||||
"hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U="
|
||||
},
|
||||
"gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": {
|
||||
"gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": {
|
||||
"name": "gobject",
|
||||
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
|
||||
"hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="
|
||||
"url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
|
||||
"hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="
|
||||
},
|
||||
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
|
||||
"name": "gtk4_layer_shell",
|
||||
@@ -44,15 +49,15 @@
|
||||
"url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz",
|
||||
"hash": "sha256-h9T4iT704I8iSXNgj/6/lCaKgTgLp5wS6IQZaMgKohI="
|
||||
},
|
||||
"N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3": {
|
||||
"N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI": {
|
||||
"name": "imgui",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
|
||||
},
|
||||
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
|
||||
"N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": {
|
||||
"name": "iterm2_themes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
"hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="
|
||||
},
|
||||
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
||||
"name": "jetbrains_mono",
|
||||
@@ -116,12 +121,12 @@
|
||||
},
|
||||
"uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": {
|
||||
"name": "uucode",
|
||||
"url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
"hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="
|
||||
},
|
||||
"vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": {
|
||||
"name": "vaxis",
|
||||
"url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
"hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="
|
||||
},
|
||||
"N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": {
|
||||
@@ -139,14 +144,14 @@
|
||||
"url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz",
|
||||
"hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM="
|
||||
},
|
||||
"z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": {
|
||||
"z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": {
|
||||
"name": "z2d",
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
|
||||
"hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
"hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="
|
||||
},
|
||||
"zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": {
|
||||
"name": "zf",
|
||||
"url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
"hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="
|
||||
},
|
||||
"zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": {
|
||||
|
||||
38
build.zig.zon.nix
generated
38
build.zig.zon.nix
generated
@@ -82,6 +82,14 @@
|
||||
fetcher.${proto};
|
||||
in
|
||||
linkFarm name [
|
||||
{
|
||||
name = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr";
|
||||
path = fetchZigArtifact {
|
||||
name = "bindings";
|
||||
url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ";
|
||||
path = fetchZigArtifact {
|
||||
@@ -123,11 +131,11 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV";
|
||||
name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-";
|
||||
path = fetchZigArtifact {
|
||||
name = "gobject";
|
||||
url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst";
|
||||
hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=";
|
||||
url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst";
|
||||
hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=";
|
||||
};
|
||||
}
|
||||
{
|
||||
@@ -155,19 +163,19 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3";
|
||||
name = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI";
|
||||
path = fetchZigArtifact {
|
||||
name = "imgui";
|
||||
url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz";
|
||||
hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=";
|
||||
url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
|
||||
name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7";
|
||||
path = fetchZigArtifact {
|
||||
name = "iterm2_themes";
|
||||
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
|
||||
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
|
||||
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz";
|
||||
hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=";
|
||||
};
|
||||
}
|
||||
{
|
||||
@@ -270,7 +278,7 @@ in
|
||||
name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E";
|
||||
path = fetchZigArtifact {
|
||||
name = "uucode";
|
||||
url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz";
|
||||
url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz";
|
||||
hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=";
|
||||
};
|
||||
}
|
||||
@@ -278,7 +286,7 @@ in
|
||||
name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS";
|
||||
path = fetchZigArtifact {
|
||||
name = "vaxis";
|
||||
url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz";
|
||||
url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz";
|
||||
hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=";
|
||||
};
|
||||
}
|
||||
@@ -307,18 +315,18 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T";
|
||||
name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ";
|
||||
path = fetchZigArtifact {
|
||||
name = "z2d";
|
||||
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz";
|
||||
hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=";
|
||||
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz";
|
||||
hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh";
|
||||
path = fetchZigArtifact {
|
||||
name = "zf";
|
||||
url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz";
|
||||
url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz";
|
||||
hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=";
|
||||
};
|
||||
}
|
||||
|
||||
15
build.zig.zon.txt
generated
15
build.zig.zon.txt
generated
@@ -1,16 +1,17 @@
|
||||
git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732
|
||||
https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz
|
||||
https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz
|
||||
https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz
|
||||
https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
|
||||
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
|
||||
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
|
||||
https://deps.files.ghostty.org/gettext-0.24.tar.gz
|
||||
https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz
|
||||
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
|
||||
https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst
|
||||
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
|
||||
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
||||
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
|
||||
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
|
||||
https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz
|
||||
https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz
|
||||
https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz
|
||||
https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz
|
||||
@@ -20,16 +21,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e
|
||||
https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz
|
||||
https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz
|
||||
https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz
|
||||
https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
|
||||
https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
|
||||
https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz
|
||||
https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz
|
||||
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
|
||||
https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
|
||||
https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz
|
||||
https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz
|
||||
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
|
||||
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
||||
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
|
||||
https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
|
||||
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz
|
||||
https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
|
||||
https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
|
||||
https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz
|
||||
https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz
|
||||
https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz
|
||||
|
||||
100
dist/linux/ghostty_nautilus.py
vendored
100
dist/linux/ghostty_nautilus.py
vendored
@@ -17,81 +17,45 @@
|
||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from os.path import isdir
|
||||
from gi import require_version
|
||||
from gi.repository import Nautilus, GObject, Gio, GLib
|
||||
from gi.repository import Nautilus, GObject, Gio
|
||||
|
||||
|
||||
class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
session = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
self._systemd = None
|
||||
# Check if the this system runs under systemd, per sd_booted(3)
|
||||
if isdir('/run/systemd/system/'):
|
||||
self._systemd = Gio.DBusProxy.new_sync(session,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
"org.freedesktop.systemd1",
|
||||
"/org/freedesktop/systemd1",
|
||||
"org.freedesktop.systemd1.Manager", None)
|
||||
|
||||
def _open_terminal(self, path):
|
||||
def open_in_ghostty_activated(_menu, paths):
|
||||
for path in paths:
|
||||
cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false']
|
||||
child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
|
||||
if self._systemd:
|
||||
# Move new terminal into a dedicated systemd scope to make systemd
|
||||
# track the terminal separately; in particular this makes systemd
|
||||
# keep a separate CPU and memory account for the terminal which in turn
|
||||
# ensures that oomd doesn't take nautilus down if a process in
|
||||
# ghostty consumes a lot of memory.
|
||||
pid = int(child.get_identifier())
|
||||
props = [("PIDs", GLib.Variant('au', [pid])),
|
||||
('CollectMode', GLib.Variant('s', 'inactive-or-failed'))]
|
||||
name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid)
|
||||
args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, []))
|
||||
self._systemd.call_sync('StartTransientUnit', args,
|
||||
Gio.DBusCallFlags.NO_AUTO_START, 500, None)
|
||||
Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
|
||||
|
||||
def _menu_item_activated(self, _menu, paths):
|
||||
for path in paths:
|
||||
self._open_terminal(path)
|
||||
|
||||
def _make_item(self, name, paths):
|
||||
def get_paths_to_open(files):
|
||||
paths = []
|
||||
for file in files:
|
||||
location = file.get_location() if file.is_directory() else file.get_parent_location()
|
||||
path = location.get_path()
|
||||
if path and path not in paths:
|
||||
paths.append(path)
|
||||
if 10 < len(paths):
|
||||
# Let's not open anything if the user selected a lot of directories,
|
||||
# to avoid accidentally spamming their desktop with dozends of
|
||||
# new windows or tabs. Ten is a totally arbitrary limit :)
|
||||
return []
|
||||
else:
|
||||
return paths
|
||||
|
||||
|
||||
def get_items_for_files(name, files):
|
||||
paths = get_paths_to_open(files)
|
||||
if paths:
|
||||
item = Nautilus.MenuItem(name=name, label='Open in Ghostty',
|
||||
icon='com.mitchellh.ghostty')
|
||||
item.connect('activate', self._menu_item_activated, paths)
|
||||
return item
|
||||
item.connect('activate', open_in_ghostty_activated, paths)
|
||||
return [item]
|
||||
else:
|
||||
return []
|
||||
|
||||
def _paths_to_open(self, files):
|
||||
paths = []
|
||||
for file in files:
|
||||
location = file.get_location() if file.is_directory() else file.get_parent_location()
|
||||
path = location.get_path()
|
||||
if path and path not in paths:
|
||||
paths.append(path)
|
||||
if 10 < len(paths):
|
||||
# Let's not open anything if the user selected a lot of directories,
|
||||
# to avoid accidentally spamming their desktop with dozends of
|
||||
# new windows or tabs. Ten is a totally arbitrary limit :)
|
||||
return []
|
||||
else:
|
||||
return paths
|
||||
|
||||
def get_file_items(self, *args):
|
||||
# Nautilus 3.0 API passes args (window, files), 4.0 API just passes files
|
||||
files = args[0] if len(args) == 1 else args[1]
|
||||
paths = self._paths_to_open(files)
|
||||
if paths:
|
||||
return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)]
|
||||
else:
|
||||
return []
|
||||
class GhosttyMenuProvider(GObject.GObject, Nautilus.MenuProvider):
|
||||
def get_file_items(self, files):
|
||||
return get_items_for_files('GhosttyNautilus::open_in_ghostty', files)
|
||||
|
||||
def get_background_items(self, *args):
|
||||
# Nautilus 3.0 API passes args (window, file), 4.0 API just passes file
|
||||
file = args[0] if len(args) == 1 else args[1]
|
||||
paths = self._paths_to_open([file])
|
||||
if paths:
|
||||
return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)]
|
||||
else:
|
||||
return []
|
||||
def get_background_items(self, file):
|
||||
return get_items_for_files('GhosttyNautilus::open_folder_in_ghostty', [file])
|
||||
|
||||
66
flake.lock
generated
66
flake.lock
generated
@@ -3,11 +3,11 @@
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"lastModified": 1761588595,
|
||||
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -34,36 +34,44 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768068402,
|
||||
"narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 315532800,
|
||||
"narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=",
|
||||
"rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469",
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1758360447,
|
||||
"narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=",
|
||||
"rev": "8eaee110344796db060382e15d3af0a9fc396e0e",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"zig": "zig",
|
||||
"zon2nix": "zon2nix"
|
||||
@@ -97,11 +105,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1760401936,
|
||||
"narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=",
|
||||
"lastModified": 1763295135,
|
||||
"narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=",
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"rev": "365085b6652259753b598d43b723858184980bbe",
|
||||
"rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -112,20 +120,22 @@
|
||||
},
|
||||
"zon2nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1758405547,
|
||||
"narHash": "sha256-WgaDgvIZMPvlZcZrpPMjkaalTBnGF2lTG+62znXctWM=",
|
||||
"lastModified": 1768231828,
|
||||
"narHash": "sha256-wL/8Iij4T2OLkhHcc4NieOjf7YeJffaUYbCiCqKv/+0=",
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
|
||||
186
flake.nix
186
flake.nix
@@ -6,7 +6,9 @@
|
||||
# glibc versions used by our dependencies from Nix are compatible with the
|
||||
# system glibc that the user is building for.
|
||||
#
|
||||
# We are currently on unstable to get Zig 0.15 for our package.nix
|
||||
# We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and
|
||||
# Gnome 49/Gtk 4.20.
|
||||
#
|
||||
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
@@ -26,12 +28,16 @@
|
||||
};
|
||||
|
||||
zon2nix = {
|
||||
url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245";
|
||||
url = "github:jcollie/zon2nix?rev=c28e93f3ba133d4c1b1d65224e2eebede61fd071";
|
||||
inputs = {
|
||||
# Don't override nixpkgs until Zig 0.15 is available in the Nix branch
|
||||
# we are using for "normal" builds.
|
||||
#
|
||||
# nixpkgs.follows = "nixpkgs";
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -41,92 +47,102 @@
|
||||
nixpkgs,
|
||||
zig,
|
||||
zon2nix,
|
||||
home-manager,
|
||||
...
|
||||
}:
|
||||
builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
|
||||
builtins.map (
|
||||
system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShell.${system} = pkgs.callPackage ./nix/devShell.nix {
|
||||
zig = zig.packages.${system}."0.15.2";
|
||||
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
|
||||
zon2nix = zon2nix;
|
||||
}: let
|
||||
inherit (nixpkgs) lib legacyPackages;
|
||||
|
||||
python3 = pkgs.python3.override {
|
||||
self = pkgs.python3;
|
||||
packageOverrides = pyfinal: pyprev: {
|
||||
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
|
||||
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
|
||||
};
|
||||
};
|
||||
# Our supported systems are the same supported systems as the Zig binaries.
|
||||
platforms = lib.attrNames zig.packages;
|
||||
|
||||
# It's not always possible to build Ghostty with Nix for each system,
|
||||
# one such example being macOS due to missing Swift 6 and xcodebuild
|
||||
# support in the Nix ecosystem. Therefore for things like package outputs
|
||||
# we need to limit the attributes we expose.
|
||||
buildablePlatforms = lib.filter (p: !(lib.systems.elaborate p).isDarwin) platforms;
|
||||
|
||||
forAllPlatforms = f: lib.genAttrs platforms (s: f legacyPackages.${s});
|
||||
forBuildablePlatforms = f: lib.genAttrs buildablePlatforms (s: f legacyPackages.${s});
|
||||
in {
|
||||
devShell = forAllPlatforms (pkgs:
|
||||
pkgs.callPackage ./nix/devShell.nix {
|
||||
zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.15.2";
|
||||
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
|
||||
zon2nix = zon2nix;
|
||||
|
||||
python3 = pkgs.python3.override {
|
||||
self = pkgs.python3;
|
||||
packageOverrides = pyfinal: pyprev: {
|
||||
blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {};
|
||||
ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {};
|
||||
};
|
||||
|
||||
packages.${system} = let
|
||||
mkArgs = optimize: {
|
||||
inherit optimize;
|
||||
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
};
|
||||
in rec {
|
||||
deps = pkgs.callPackage ./build.zig.zon.nix {};
|
||||
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
|
||||
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
|
||||
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
|
||||
|
||||
ghostty = ghostty-releasefast;
|
||||
default = ghostty;
|
||||
};
|
||||
|
||||
formatter.${system} = pkgs.alejandra;
|
||||
|
||||
apps.${system} = let
|
||||
runVM = (
|
||||
module: let
|
||||
vm = import ./nix/vm/create.nix {
|
||||
inherit system module nixpkgs;
|
||||
overlay = self.overlays.debug;
|
||||
};
|
||||
program = pkgs.writeShellScript "run-ghostty-vm" ''
|
||||
SHARED_DIR=$(pwd)
|
||||
export SHARED_DIR
|
||||
|
||||
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
|
||||
'';
|
||||
in {
|
||||
type = "app";
|
||||
program = "${program}";
|
||||
}
|
||||
);
|
||||
in {
|
||||
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
|
||||
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
|
||||
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
|
||||
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
|
||||
x11-gnome = runVM ./nix/vm/x11-gnome.nix;
|
||||
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
|
||||
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
|
||||
};
|
||||
}
|
||||
# Our supported systems are the same supported systems as the Zig binaries.
|
||||
) (builtins.attrNames zig.packages)
|
||||
)
|
||||
// {
|
||||
overlays = {
|
||||
default = self.overlays.releasefast;
|
||||
releasefast = final: prev: {
|
||||
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast;
|
||||
};
|
||||
debug = final: prev: {
|
||||
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug;
|
||||
});
|
||||
|
||||
packages =
|
||||
forAllPlatforms (pkgs: {
|
||||
# Deps are needed for environmental setup on macOS
|
||||
deps = pkgs.callPackage ./build.zig.zon.nix {};
|
||||
})
|
||||
// forBuildablePlatforms (pkgs: let
|
||||
mkArgs = optimize: {
|
||||
inherit optimize;
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
};
|
||||
in rec {
|
||||
ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
|
||||
ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
|
||||
ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
|
||||
|
||||
ghostty = ghostty-releasefast;
|
||||
default = ghostty;
|
||||
});
|
||||
|
||||
formatter = forAllPlatforms (pkgs: pkgs.alejandra);
|
||||
|
||||
apps = forBuildablePlatforms (pkgs: let
|
||||
runVM = module: desc: let
|
||||
vm = import ./nix/vm/create.nix {
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
inherit module nixpkgs;
|
||||
overlay = self.overlays.debug;
|
||||
};
|
||||
program = pkgs.writeShellScript "run-ghostty-vm" ''
|
||||
SHARED_DIR=$(pwd)
|
||||
export SHARED_DIR
|
||||
|
||||
${pkgs.lib.getExe vm.config.system.build.vm} "$@"
|
||||
'';
|
||||
in {
|
||||
type = "app";
|
||||
program = "${program}";
|
||||
meta.description = "start a vm from ${toString module}";
|
||||
};
|
||||
in {
|
||||
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
|
||||
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
|
||||
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
|
||||
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
|
||||
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
|
||||
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
|
||||
});
|
||||
|
||||
checks = forAllPlatforms (pkgs:
|
||||
import ./nix/tests.nix {
|
||||
inherit home-manager nixpkgs self;
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
});
|
||||
|
||||
overlays = {
|
||||
default = self.overlays.releasefast;
|
||||
releasefast = final: prev: {
|
||||
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast;
|
||||
};
|
||||
debug = final: prev: {
|
||||
ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug;
|
||||
};
|
||||
create-vm = import ./nix/vm/create.nix;
|
||||
create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix;
|
||||
create-gnome-vm = import ./nix/vm/create-gnome.nix;
|
||||
create-plasma6-vm = import ./nix/vm/create-plasma6.nix;
|
||||
create-xfce-vm = import ./nix/vm/create-xfce.nix;
|
||||
};
|
||||
};
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = ["https://ghostty.cachix.org"];
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
|
||||
"sha256": "8bfec500e00926f679853ee23d67cc392d3c3181733ca4704738651d3f70baa3"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -31,9 +37,9 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst",
|
||||
"dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV",
|
||||
"sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a"
|
||||
"url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
|
||||
"dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
|
||||
"sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
@@ -55,15 +61,15 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
"sha256": "a05fd01e04cf11ab781e28387c621d2e420f1e6044c8e27a25e603ea99ef7860"
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
|
||||
"sha256": "c816c20e8c75f3e15ae867350e79925502d1a6a85938bb1a73b8927e5f31f9cb"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
"dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
|
||||
"sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
@@ -139,13 +145,13 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz",
|
||||
"dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E",
|
||||
"sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz",
|
||||
"dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS",
|
||||
"sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6"
|
||||
},
|
||||
@@ -169,13 +175,13 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
|
||||
"dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
|
||||
"sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10"
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
"dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||
"sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
"url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
|
||||
"dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
|
||||
"sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568"
|
||||
},
|
||||
|
||||
@@ -66,6 +66,14 @@ typedef enum {
|
||||
GHOSTTY_MOUSE_LEFT,
|
||||
GHOSTTY_MOUSE_RIGHT,
|
||||
GHOSTTY_MOUSE_MIDDLE,
|
||||
GHOSTTY_MOUSE_FOUR,
|
||||
GHOSTTY_MOUSE_FIVE,
|
||||
GHOSTTY_MOUSE_SIX,
|
||||
GHOSTTY_MOUSE_SEVEN,
|
||||
GHOSTTY_MOUSE_EIGHT,
|
||||
GHOSTTY_MOUSE_NINE,
|
||||
GHOSTTY_MOUSE_TEN,
|
||||
GHOSTTY_MOUSE_ELEVEN,
|
||||
} ghostty_input_mouse_button_e;
|
||||
|
||||
typedef enum {
|
||||
@@ -102,6 +110,13 @@ typedef enum {
|
||||
GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
|
||||
} ghostty_input_mods_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
|
||||
GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
|
||||
GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
|
||||
GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
|
||||
} ghostty_binding_flags_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_RELEASE,
|
||||
GHOSTTY_ACTION_PRESS,
|
||||
@@ -317,12 +332,14 @@ typedef struct {
|
||||
typedef enum {
|
||||
GHOSTTY_TRIGGER_PHYSICAL,
|
||||
GHOSTTY_TRIGGER_UNICODE,
|
||||
GHOSTTY_TRIGGER_CATCH_ALL,
|
||||
} ghostty_input_trigger_tag_e;
|
||||
|
||||
typedef union {
|
||||
ghostty_input_key_e translated;
|
||||
ghostty_input_key_e physical;
|
||||
uint32_t unicode;
|
||||
// catch_all has no payload
|
||||
} ghostty_input_trigger_key_u;
|
||||
|
||||
typedef struct {
|
||||
@@ -414,6 +431,12 @@ typedef union {
|
||||
ghostty_platform_ios_s ios;
|
||||
} ghostty_platform_u;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
|
||||
GHOSTTY_SURFACE_CONTEXT_TAB = 1,
|
||||
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
|
||||
} ghostty_surface_context_e;
|
||||
|
||||
typedef struct {
|
||||
ghostty_platform_e platform_tag;
|
||||
ghostty_platform_u platform;
|
||||
@@ -426,6 +449,7 @@ typedef struct {
|
||||
size_t env_var_count;
|
||||
const char* initial_input;
|
||||
bool wait_after_command;
|
||||
ghostty_surface_context_e context;
|
||||
} ghostty_surface_config_s;
|
||||
|
||||
typedef struct {
|
||||
@@ -452,6 +476,12 @@ typedef struct {
|
||||
size_t len;
|
||||
} ghostty_config_color_list_s;
|
||||
|
||||
// config.RepeatableCommand
|
||||
typedef struct {
|
||||
const ghostty_command_s* commands;
|
||||
size_t len;
|
||||
} ghostty_config_command_list_s;
|
||||
|
||||
// config.Palette
|
||||
typedef struct {
|
||||
ghostty_config_color_s colors[256];
|
||||
@@ -512,6 +542,12 @@ typedef enum {
|
||||
GHOSTTY_GOTO_SPLIT_RIGHT,
|
||||
} ghostty_action_goto_split_e;
|
||||
|
||||
// apprt.action.GotoWindow
|
||||
typedef enum {
|
||||
GHOSTTY_GOTO_WINDOW_PREVIOUS,
|
||||
GHOSTTY_GOTO_WINDOW_NEXT,
|
||||
} ghostty_action_goto_window_e;
|
||||
|
||||
// apprt.action.ResizeSplit.Direction
|
||||
typedef enum {
|
||||
GHOSTTY_RESIZE_SPLIT_UP,
|
||||
@@ -573,6 +609,12 @@ typedef enum {
|
||||
GHOSTTY_QUIT_TIMER_STOP,
|
||||
} ghostty_action_quit_timer_e;
|
||||
|
||||
// apprt.action.Readonly
|
||||
typedef enum {
|
||||
GHOSTTY_READONLY_OFF,
|
||||
GHOSTTY_READONLY_ON,
|
||||
} ghostty_action_readonly_e;
|
||||
|
||||
// apprt.action.DesktopNotification.C
|
||||
typedef struct {
|
||||
const char* title;
|
||||
@@ -584,6 +626,12 @@ typedef struct {
|
||||
const char* title;
|
||||
} ghostty_action_set_title_s;
|
||||
|
||||
// apprt.action.PromptTitle
|
||||
typedef enum {
|
||||
GHOSTTY_PROMPT_TITLE_SURFACE,
|
||||
GHOSTTY_PROMPT_TITLE_TAB,
|
||||
} ghostty_action_prompt_title_e;
|
||||
|
||||
// apprt.action.Pwd.C
|
||||
typedef struct {
|
||||
const char* pwd;
|
||||
@@ -671,6 +719,27 @@ typedef struct {
|
||||
ghostty_input_trigger_s trigger;
|
||||
} ghostty_action_key_sequence_s;
|
||||
|
||||
// apprt.action.KeyTable.Tag
|
||||
typedef enum {
|
||||
GHOSTTY_KEY_TABLE_ACTIVATE,
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE,
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
|
||||
} ghostty_action_key_table_tag_e;
|
||||
|
||||
// apprt.action.KeyTable.CValue
|
||||
typedef union {
|
||||
struct {
|
||||
const char *name;
|
||||
size_t len;
|
||||
} activate;
|
||||
} ghostty_action_key_table_u;
|
||||
|
||||
// apprt.action.KeyTable.C
|
||||
typedef struct {
|
||||
ghostty_action_key_table_tag_e tag;
|
||||
ghostty_action_key_table_u value;
|
||||
} ghostty_action_key_table_s;
|
||||
|
||||
// apprt.action.ColorKind
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
|
||||
@@ -714,6 +783,7 @@ typedef struct {
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
|
||||
GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
|
||||
} ghostty_action_close_tab_mode_e;
|
||||
|
||||
// apprt.surface.Message.ChildExited
|
||||
@@ -784,9 +854,11 @@ typedef enum {
|
||||
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
|
||||
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
|
||||
GHOSTTY_ACTION_TOGGLE_VISIBILITY,
|
||||
GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
|
||||
GHOSTTY_ACTION_MOVE_TAB,
|
||||
GHOSTTY_ACTION_GOTO_TAB,
|
||||
GHOSTTY_ACTION_GOTO_SPLIT,
|
||||
GHOSTTY_ACTION_GOTO_WINDOW,
|
||||
GHOSTTY_ACTION_RESIZE_SPLIT,
|
||||
GHOSTTY_ACTION_EQUALIZE_SPLITS,
|
||||
GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
|
||||
@@ -813,6 +885,7 @@ typedef enum {
|
||||
GHOSTTY_ACTION_FLOAT_WINDOW,
|
||||
GHOSTTY_ACTION_SECURE_INPUT,
|
||||
GHOSTTY_ACTION_KEY_SEQUENCE,
|
||||
GHOSTTY_ACTION_KEY_TABLE,
|
||||
GHOSTTY_ACTION_COLOR_CHANGE,
|
||||
GHOSTTY_ACTION_RELOAD_CONFIG,
|
||||
GHOSTTY_ACTION_CONFIG_CHANGE,
|
||||
@@ -830,6 +903,7 @@ typedef enum {
|
||||
GHOSTTY_ACTION_END_SEARCH,
|
||||
GHOSTTY_ACTION_SEARCH_TOTAL,
|
||||
GHOSTTY_ACTION_SEARCH_SELECTED,
|
||||
GHOSTTY_ACTION_READONLY,
|
||||
} ghostty_action_tag_e;
|
||||
|
||||
typedef union {
|
||||
@@ -838,6 +912,7 @@ typedef union {
|
||||
ghostty_action_move_tab_s move_tab;
|
||||
ghostty_action_goto_tab_e goto_tab;
|
||||
ghostty_action_goto_split_e goto_split;
|
||||
ghostty_action_goto_window_e goto_window;
|
||||
ghostty_action_resize_split_s resize_split;
|
||||
ghostty_action_size_limit_s size_limit;
|
||||
ghostty_action_initial_size_s initial_size;
|
||||
@@ -846,6 +921,7 @@ typedef union {
|
||||
ghostty_action_inspector_e inspector;
|
||||
ghostty_action_desktop_notification_s desktop_notification;
|
||||
ghostty_action_set_title_s set_title;
|
||||
ghostty_action_prompt_title_e prompt_title;
|
||||
ghostty_action_pwd_s pwd;
|
||||
ghostty_action_mouse_shape_e mouse_shape;
|
||||
ghostty_action_mouse_visibility_e mouse_visibility;
|
||||
@@ -855,6 +931,7 @@ typedef union {
|
||||
ghostty_action_float_window_e float_window;
|
||||
ghostty_action_secure_input_e secure_input;
|
||||
ghostty_action_key_sequence_s key_sequence;
|
||||
ghostty_action_key_table_s key_table;
|
||||
ghostty_action_color_change_s color_change;
|
||||
ghostty_action_reload_config_s reload_config;
|
||||
ghostty_action_config_change_s config_change;
|
||||
@@ -866,6 +943,7 @@ typedef union {
|
||||
ghostty_action_start_search_s start_search;
|
||||
ghostty_action_search_total_s search_total;
|
||||
ghostty_action_search_selected_s search_selected;
|
||||
ghostty_action_readonly_e readonly;
|
||||
} ghostty_action_u;
|
||||
|
||||
typedef struct {
|
||||
@@ -946,6 +1024,7 @@ ghostty_config_t ghostty_config_new();
|
||||
void ghostty_config_free(ghostty_config_t);
|
||||
ghostty_config_t ghostty_config_clone(ghostty_config_t);
|
||||
void ghostty_config_load_cli_args(ghostty_config_t);
|
||||
void ghostty_config_load_file(ghostty_config_t, const char*);
|
||||
void ghostty_config_load_default_files(ghostty_config_t);
|
||||
void ghostty_config_load_recursive_files(ghostty_config_t);
|
||||
void ghostty_config_finalize(ghostty_config_t);
|
||||
@@ -979,7 +1058,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t,
|
||||
void ghostty_surface_free(ghostty_surface_t);
|
||||
void* ghostty_surface_userdata(ghostty_surface_t);
|
||||
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
|
||||
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
|
||||
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
|
||||
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
|
||||
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
|
||||
bool ghostty_surface_process_exited(ghostty_surface_t);
|
||||
@@ -994,9 +1073,10 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
|
||||
ghostty_color_scheme_e);
|
||||
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
|
||||
ghostty_input_mods_e);
|
||||
void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*);
|
||||
bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t,
|
||||
ghostty_input_key_s,
|
||||
ghostty_binding_flags_e*);
|
||||
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
|
||||
void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
|
||||
bool ghostty_surface_mouse_captured(ghostty_surface_t);
|
||||
|
||||
@@ -63,24 +63,26 @@ typedef enum {
|
||||
GHOSTTY_OSC_COMMAND_INVALID = 0,
|
||||
GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
|
||||
GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
|
||||
GHOSTTY_OSC_COMMAND_PROMPT_START = 3,
|
||||
GHOSTTY_OSC_COMMAND_PROMPT_END = 4,
|
||||
GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5,
|
||||
GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6,
|
||||
GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7,
|
||||
GHOSTTY_OSC_COMMAND_REPORT_PWD = 8,
|
||||
GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9,
|
||||
GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10,
|
||||
GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11,
|
||||
GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12,
|
||||
GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13,
|
||||
GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20,
|
||||
GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
|
||||
GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
|
||||
GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
|
||||
GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
|
||||
GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
|
||||
GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
|
||||
GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
|
||||
GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
|
||||
GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
|
||||
GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
|
||||
GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
|
||||
} GhosttyOscCommandType;
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,5 +100,20 @@
|
||||
<false/>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=</string>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.mitchellh.ghosttySurfaceId</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Ghostty Surface Identifier</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
|
||||
remoteInfo = Ghostty;
|
||||
};
|
||||
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
|
||||
@@ -42,6 +49,7 @@
|
||||
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
|
||||
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
|
||||
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
|
||||
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
|
||||
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; };
|
||||
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
|
||||
@@ -66,11 +74,13 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/macOS/AppDelegate.swift,
|
||||
"App/macOS/AppDelegate+Ghostty.swift",
|
||||
App/macOS/main.swift,
|
||||
App/macOS/MainMenu.xib,
|
||||
Features/About/About.xib,
|
||||
Features/About/AboutController.swift,
|
||||
Features/About/AboutView.swift,
|
||||
Features/About/CyclingIconView.swift,
|
||||
"Features/App Intents/CloseTerminalIntent.swift",
|
||||
"Features/App Intents/CommandPaletteIntent.swift",
|
||||
"Features/App Intents/Entities/CommandEntity.swift",
|
||||
@@ -95,6 +105,7 @@
|
||||
Features/QuickTerminal/QuickTerminal.xib,
|
||||
Features/QuickTerminal/QuickTerminalController.swift,
|
||||
Features/QuickTerminal/QuickTerminalPosition.swift,
|
||||
Features/QuickTerminal/QuickTerminalRestorableState.swift,
|
||||
Features/QuickTerminal/QuickTerminalScreen.swift,
|
||||
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
|
||||
Features/QuickTerminal/QuickTerminalSize.swift,
|
||||
@@ -115,7 +126,9 @@
|
||||
Features/Terminal/ErrorView.swift,
|
||||
Features/Terminal/TerminalController.swift,
|
||||
Features/Terminal/TerminalRestorable.swift,
|
||||
Features/Terminal/TerminalTabColor.swift,
|
||||
Features/Terminal/TerminalView.swift,
|
||||
Features/Terminal/TerminalViewContainer.swift,
|
||||
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
|
||||
"Features/Terminal/Window Styles/Terminal.xib",
|
||||
"Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib",
|
||||
@@ -135,19 +148,19 @@
|
||||
Features/Update/UpdateSimulator.swift,
|
||||
Features/Update/UpdateViewModel.swift,
|
||||
"Ghostty/FullscreenMode+Extension.swift",
|
||||
Ghostty/Ghostty.Command.swift,
|
||||
Ghostty/Ghostty.Error.swift,
|
||||
Ghostty/Ghostty.Event.swift,
|
||||
Ghostty/Ghostty.Input.swift,
|
||||
Ghostty/Ghostty.Surface.swift,
|
||||
Ghostty/InspectorView.swift,
|
||||
"Ghostty/NSEvent+Extension.swift",
|
||||
Ghostty/SurfaceScrollView.swift,
|
||||
Ghostty/SurfaceView_AppKit.swift,
|
||||
"Ghostty/Surface View/InspectorView.swift",
|
||||
"Ghostty/Surface View/SurfaceDragSource.swift",
|
||||
"Ghostty/Surface View/SurfaceGrabHandle.swift",
|
||||
"Ghostty/Surface View/SurfaceScrollView.swift",
|
||||
"Ghostty/Surface View/SurfaceView_AppKit.swift",
|
||||
Helpers/AppInfo.swift,
|
||||
Helpers/CodableBridge.swift,
|
||||
Helpers/Cursor.swift,
|
||||
Helpers/DraggableWindowView.swift,
|
||||
Helpers/ExpiringUndoManager.swift,
|
||||
"Helpers/Extensions/Double+Extension.swift",
|
||||
"Helpers/Extensions/EventModifiers+Extension.swift",
|
||||
@@ -155,13 +168,16 @@
|
||||
"Helpers/Extensions/KeyboardShortcut+Extension.swift",
|
||||
"Helpers/Extensions/NSAppearance+Extension.swift",
|
||||
"Helpers/Extensions/NSApplication+Extension.swift",
|
||||
"Helpers/Extensions/NSColor+Extension.swift",
|
||||
"Helpers/Extensions/NSImage+Extension.swift",
|
||||
"Helpers/Extensions/NSMenu+Extension.swift",
|
||||
"Helpers/Extensions/NSMenuItem+Extension.swift",
|
||||
"Helpers/Extensions/NSPasteboard+Extension.swift",
|
||||
"Helpers/Extensions/NSScreen+Extension.swift",
|
||||
"Helpers/Extensions/NSView+Extension.swift",
|
||||
"Helpers/Extensions/NSWindow+Extension.swift",
|
||||
"Helpers/Extensions/NSWorkspace+Extension.swift",
|
||||
"Helpers/Extensions/Transferable+Extension.swift",
|
||||
"Helpers/Extensions/UndoManager+Extension.swift",
|
||||
"Helpers/Extensions/View+Extension.swift",
|
||||
Helpers/Fullscreen.swift,
|
||||
@@ -183,18 +199,26 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/iOS/iOSApp.swift,
|
||||
Ghostty/SurfaceView_UIKit.swift,
|
||||
"Ghostty/Surface View/SurfaceView_UIKit.swift",
|
||||
);
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = "<group>"; };
|
||||
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
|
||||
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
810ACC9C2E9D3301004F8F92 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -251,6 +275,7 @@
|
||||
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
|
||||
81F82BC72E82815D001EDFA7 /* Sources */,
|
||||
A54F45F42E1F047A0046BD5C /* Tests */,
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
|
||||
A5D495A3299BECBA00DD1313 /* Frameworks */,
|
||||
A5A1F8862A489D7400D1E8BC /* Resources */,
|
||||
A5B30532299BEAAA0047F10C /* Products */,
|
||||
@@ -263,6 +288,7 @@
|
||||
A5B30531299BEAAA0047F10C /* Ghostty.app */,
|
||||
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
|
||||
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
|
||||
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -279,6 +305,29 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */;
|
||||
buildPhases = (
|
||||
810ACC9B2E9D3301004F8F92 /* Sources */,
|
||||
810ACC9C2E9D3301004F8F92 /* Frameworks */,
|
||||
810ACC9D2E9D3301004F8F92 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
|
||||
);
|
||||
name = GhosttyUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = GhosttyUITests;
|
||||
productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
|
||||
@@ -352,9 +401,13 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 1610;
|
||||
TargetAttributes = {
|
||||
810ACC9E2E9D3301004F8F92 = {
|
||||
CreatedOnToolsVersion = 26.1;
|
||||
TestTargetID = A5B30530299BEAAA0047F10C;
|
||||
};
|
||||
A54F45F22E1F047A0046BD5C = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = A5B30530299BEAAA0047F10C;
|
||||
@@ -387,11 +440,19 @@
|
||||
A5B30530299BEAAA0047F10C /* Ghostty */,
|
||||
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
|
||||
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
|
||||
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
810ACC9D2E9D3301004F8F92 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45F12E1F047A0046BD5C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -430,6 +491,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
810ACC9B2E9D3301004F8F92 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45EF2E1F047A0046BD5C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -454,6 +522,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */;
|
||||
};
|
||||
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
@@ -571,6 +644,73 @@
|
||||
};
|
||||
name = ReleaseLocal;
|
||||
};
|
||||
810ACCA82E9D3302004F8F92 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
810ACCA92E9D3302004F8F92 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = ReleaseLocal;
|
||||
};
|
||||
A54F45F92E1F047A0046BD5C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -987,6 +1127,16 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
810ACCA82E9D3302004F8F92 /* Debug */,
|
||||
810ACCA92E9D3302004F8F92 /* Release */,
|
||||
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = ReleaseLocal;
|
||||
};
|
||||
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
ReferencedContainer = "container:Ghostty.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "810ACC9E2E9D3301004F8F92"
|
||||
BuildableName = "GhosttyUITests.xctest"
|
||||
BlueprintName = "GhosttyUITests"
|
||||
ReferencedContainer = "container:Ghostty.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
||||
34
macos/GhosttyUITests/AppKitExtensions.swift
Normal file
34
macos/GhosttyUITests/AppKitExtensions.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// AppKitExtensions.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 27.10.2025.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
extension NSColor {
|
||||
var isLightColor: Bool {
|
||||
return self.luminance > 0.5
|
||||
}
|
||||
|
||||
var luminance: Double {
|
||||
var r: CGFloat = 0
|
||||
var g: CGFloat = 0
|
||||
var b: CGFloat = 0
|
||||
var a: CGFloat = 0
|
||||
|
||||
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }
|
||||
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (0.299 * r) + (0.587 * g) + (0.114 * b)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSImage {
|
||||
func colorAt(x: Int, y: Int) -> NSColor? {
|
||||
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
return NSBitmapImageRep(cgImage: cgImage).colorAt(x: x, y: y)
|
||||
}
|
||||
}
|
||||
59
macos/GhosttyUITests/GhosttyCustomConfigCase.swift
Normal file
59
macos/GhosttyUITests/GhosttyCustomConfigCase.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// GhosttyCustomConfigCase.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 16.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
class GhosttyCustomConfigCase: XCTestCase {
|
||||
/// We only want run these UI tests
|
||||
/// when testing manually with Xcode IDE
|
||||
///
|
||||
/// So that we don't have to wait for each ci check
|
||||
/// to run these tedious tests
|
||||
override class var defaultTestSuite: XCTestSuite {
|
||||
// https://lldb.llvm.org/cpp_reference/PlatformDarwin_8cpp_source.html#:~:text==%20%22-,IDE_DISABLED_OS_ACTIVITY_DT_MODE
|
||||
|
||||
if ProcessInfo.processInfo.environment["IDE_DISABLED_OS_ACTIVITY_DT_MODE"] != nil {
|
||||
return XCTestSuite(forTestCaseClass: Self.self)
|
||||
} else {
|
||||
return XCTestSuite(name: "Skipping \(className())")
|
||||
}
|
||||
}
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var configFile: URL?
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
if let configFile {
|
||||
try FileManager.default.removeItem(at: configFile)
|
||||
}
|
||||
}
|
||||
|
||||
func updateConfig(_ newConfig: String) throws {
|
||||
if configFile == nil {
|
||||
let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("ghostty")
|
||||
configFile = temporaryConfig
|
||||
}
|
||||
try newConfig.write(to: configFile!, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
func ghosttyApplication() throws -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"])
|
||||
guard let configFile else {
|
||||
return app
|
||||
}
|
||||
app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path
|
||||
return app
|
||||
}
|
||||
}
|
||||
159
macos/GhosttyUITests/GhosttyThemeTests.swift
Normal file
159
macos/GhosttyUITests/GhosttyThemeTests.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// GhosttyThemeTests.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 27.10.2025.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
final class GhosttyThemeTests: GhosttyCustomConfigCase {
|
||||
let windowTitle = "GhosttyThemeTests"
|
||||
private func assertTitlebarAppearance(
|
||||
_ appearance: XCUIDevice.Appearance,
|
||||
for app: XCUIApplication,
|
||||
title: String? = nil,
|
||||
colorLocation: CGPoint? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws {
|
||||
for i in 0 ..< app.windows.count {
|
||||
let titleView = app.windows.element(boundBy: i).staticTexts.element(matching: NSPredicate(format: "value == '\(title ?? windowTitle)'"))
|
||||
|
||||
let image = titleView.screenshot().image
|
||||
guard let imageColor = image.colorAt(x: Int(colorLocation?.x ?? 1), y: Int(colorLocation?.y ?? 1)) else {
|
||||
throw XCTSkip("failed to get pixel color", file: file, line: line)
|
||||
}
|
||||
|
||||
switch appearance {
|
||||
case .dark:
|
||||
XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test", file: file, line: line)
|
||||
default:
|
||||
XCTAssertGreaterThanOrEqual(imageColor.luminance, 0.5, "Expected light appearance for this test", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// https://github.com/ghostty-org/ghostty/issues/8282
|
||||
@MainActor
|
||||
func testIssue8282() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
// create a split
|
||||
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
// create a new window
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLightTransparentWindowThemeWithDarkTerminal() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n window-theme=light")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLightNativeWindowThemeWithDarkTerminal() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n window-theme = light \n macos-titlebar-style = native")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadingLightTransparentWindowTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// default dark theme
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme = light")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSwitchingSystemTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
XCUIDevice.shared.appearance = .light
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadFromLightWindowThemeToDefaultTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadFromDefaultThemeToDarkWindowTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadingFromDarkThemeToSystemLightTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testQuickTerminalThemeChange() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n confirm-close-surface=false")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// close default window
|
||||
app.typeKey("w", modifierFlags: [.command])
|
||||
// open quick terminal
|
||||
app.menuBarItems["View"].firstMatch.click()
|
||||
app.menuItems["Quick Terminal"].firstMatch.click()
|
||||
let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development."
|
||||
try assertTitlebarAppearance(.light, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5)) // to avoid dark edge
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5))
|
||||
}
|
||||
}
|
||||
23
macos/GhosttyUITests/GhosttyTitleUITests.swift
Normal file
23
macos/GhosttyUITests/GhosttyTitleUITests.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// GhosttyTitleUITests.swift
|
||||
// GhosttyUITests
|
||||
//
|
||||
// Created by luca on 13.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class GhosttyTitleUITests: GhosttyCustomConfigCase {
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
try updateConfig(#"title = "GhosttyUITestsLaunchTests""#)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTitle() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
|
||||
XCTAssertEqual(app.windows.firstMatch.title, "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!")
|
||||
}
|
||||
}
|
||||
143
macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift
Normal file
143
macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// GhosttyTitlebarTabsUITests.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 16.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase {
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
|
||||
try updateConfig(
|
||||
"""
|
||||
macos-titlebar-style = tabs
|
||||
title = "GhosttyTitlebarTabsUITests"
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCustomTitlebar() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// create a split
|
||||
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
|
||||
app.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
let resetZoomButton = app.groups.buttons["ResetZoom"]
|
||||
let windowTitle = app.windows.firstMatch.title
|
||||
let titleView = app.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'"))
|
||||
|
||||
XCTAssertEqual(titleView.frame.midY, resetZoomButton.frame.midY, accuracy: 1, "Window title should be vertically centered with reset zoom button: \(titleView.frame.midY) != \(resetZoomButton.frame.midY)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryInNormalWindow() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryInFullscreen() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
app.typeKey("f", modifierFlags: [.command, .control])
|
||||
// using app to type ⌘+t might not be able to create tabs
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryAfterMovingTabs() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
|
||||
// create another 2 tabs
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
|
||||
// move to the left
|
||||
app.menuItems["_zoomLeft:"].firstMatch.click()
|
||||
|
||||
// create another window with 2 tabs
|
||||
app.windows.firstMatch.groups["Terminal pane"].typeKey("n", modifierFlags: .command)
|
||||
XCTAssertEqual(app.windows.count, 2, "There should be 2 windows")
|
||||
|
||||
// move to the right
|
||||
app.menuItems["_zoomRight:"].firstMatch.click()
|
||||
|
||||
// now second window is the first/main one in the list
|
||||
app.windows.firstMatch.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
|
||||
app.windows.element(boundBy: 1).tabs.firstMatch.click() // focus first window
|
||||
|
||||
// now the first window is the main one
|
||||
let firstTabInFirstWindow = app.windows.firstMatch.tabs.firstMatch
|
||||
let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.firstMatch
|
||||
|
||||
// drag a tab from one window to another
|
||||
firstTabInFirstWindow.press(forDuration: 0.2, thenDragTo: firstTabInSecondWindow)
|
||||
|
||||
// check tabs in the first
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
// focus another window
|
||||
app.windows.element(boundBy: 1).tabs.firstMatch.click()
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryAfterMergingAllWindows() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
|
||||
|
||||
// create another 2 windows
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
|
||||
// merge into one window, resulting 3 tabs
|
||||
app.menuItems["mergeAllWindows:"].firstMatch.click()
|
||||
|
||||
XCTAssertTrue(app.wait(for: \.tabs.count, toEqual: 3, timeout: 1), "There should be 3 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
func checkTabsGeometry(_ window: XCUIElement) {
|
||||
let closeTabButtons = window.buttons.matching(identifier: "_closeButton")
|
||||
|
||||
XCTAssertEqual(closeTabButtons.count, window.tabs.count, "Close tab buttons count should match tabs count")
|
||||
|
||||
var previousTabHeight: CGFloat?
|
||||
for idx in 0 ..< window.tabs.count {
|
||||
let currentTab = window.tabs.element(boundBy: idx)
|
||||
// focus
|
||||
currentTab.click()
|
||||
// switch to the tab
|
||||
window.typeKey("\(idx + 1)", modifierFlags: .command)
|
||||
// add a split
|
||||
window.typeKey("d", modifierFlags: .command)
|
||||
// zoom this split
|
||||
// haven't found a way to locate our reset zoom button yet..
|
||||
window.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
window.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
|
||||
if let previousHeight = previousTabHeight {
|
||||
XCTAssertEqual(currentTab.frame.height, previousHeight, accuracy: 1, "The tab's height should stay the same")
|
||||
}
|
||||
previousTabHeight = currentTab.frame.height
|
||||
|
||||
let titleFrame = currentTab.frame
|
||||
let shortcutLabelFrame = window.staticTexts.element(matching: NSPredicate(format: "value CONTAINS[c] '⌘\(idx + 1)'")).firstMatch.frame
|
||||
let closeButtonFrame = closeTabButtons.element(boundBy: idx).frame
|
||||
|
||||
XCTAssertEqual(titleFrame.midY, shortcutLabelFrame.midY, accuracy: 1, "Tab title should be vertically centered with its shortcut label: \(titleFrame.midY) != \(shortcutLabelFrame.midY)")
|
||||
XCTAssertEqual(titleFrame.midY, closeButtonFrame.midY, accuracy: 1, "Tab title should be vertically centered with its close button: \(titleFrame.midY) != \(closeButtonFrame.midY)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
@main
|
||||
struct Ghostty_iOSApp: App {
|
||||
@StateObject private var ghostty_app = Ghostty.App()
|
||||
@StateObject private var ghostty_app: Ghostty.App
|
||||
|
||||
init() {
|
||||
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
|
||||
preconditionFailure("Initialize ghostty backend failed")
|
||||
}
|
||||
_ghostty_app = StateObject(wrappedValue: Ghostty.App())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
|
||||
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: Ghostty Delegate
|
||||
|
||||
/// This implements the Ghostty app delegate protocol which is used by the Ghostty
|
||||
/// APIs for app-global information.
|
||||
extension AppDelegate: Ghostty.Delegate {
|
||||
func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? {
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else {
|
||||
continue
|
||||
}
|
||||
|
||||
for surface in controller.surfaceTree {
|
||||
if surface.id == id {
|
||||
return surface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class AppDelegate: NSObject,
|
||||
@IBOutlet private var menuSelectAll: NSMenuItem?
|
||||
@IBOutlet private var menuFindParent: NSMenuItem?
|
||||
@IBOutlet private var menuFind: NSMenuItem?
|
||||
@IBOutlet private var menuSelectionForFind: NSMenuItem?
|
||||
@IBOutlet private var menuScrollToSelection: NSMenuItem?
|
||||
@IBOutlet private var menuFindNext: NSMenuItem?
|
||||
@IBOutlet private var menuFindPrevious: NSMenuItem?
|
||||
@IBOutlet private var menuHideFindBar: NSMenuItem?
|
||||
@@ -68,6 +70,8 @@ class AppDelegate: NSObject,
|
||||
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
|
||||
@IBOutlet private var menuResetFontSize: NSMenuItem?
|
||||
@IBOutlet private var menuChangeTitle: NSMenuItem?
|
||||
@IBOutlet private var menuChangeTabTitle: NSMenuItem?
|
||||
@IBOutlet private var menuReadonly: NSMenuItem?
|
||||
@IBOutlet private var menuQuickTerminal: NSMenuItem?
|
||||
@IBOutlet private var menuTerminalInspector: NSMenuItem?
|
||||
@IBOutlet private var menuCommandPalette: NSMenuItem?
|
||||
@@ -92,16 +96,40 @@ class AppDelegate: NSObject,
|
||||
private var derivedConfig: DerivedConfig = DerivedConfig()
|
||||
|
||||
/// The ghostty global state. Only one per process.
|
||||
let ghostty: Ghostty.App = Ghostty.App()
|
||||
let ghostty: Ghostty.App
|
||||
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = ExpiringUndoManager()
|
||||
|
||||
/// The current state of the quick terminal.
|
||||
private var quickTerminalControllerState: QuickTerminalState = .uninitialized
|
||||
|
||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||
private(set) lazy var quickController = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition
|
||||
)
|
||||
var quickController: QuickTerminalController {
|
||||
switch quickTerminalControllerState {
|
||||
case .initialized(let controller):
|
||||
return controller
|
||||
|
||||
case .pendingRestore(let state):
|
||||
let controller = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition,
|
||||
baseConfig: state.baseConfig,
|
||||
restorationState: state
|
||||
)
|
||||
quickTerminalControllerState = .initialized(controller)
|
||||
return controller
|
||||
|
||||
case .uninitialized:
|
||||
let controller = QuickTerminalController(
|
||||
ghostty,
|
||||
position: derivedConfig.quickTerminalPosition,
|
||||
restorationState: nil
|
||||
)
|
||||
quickTerminalControllerState = .initialized(controller)
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages updates
|
||||
let updateController = UpdateController()
|
||||
@@ -127,6 +155,11 @@ class AppDelegate: NSObject,
|
||||
@Published private(set) var appIcon: NSImage? = nil
|
||||
|
||||
override init() {
|
||||
#if DEBUG
|
||||
ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"])
|
||||
#else
|
||||
ghostty = Ghostty.App()
|
||||
#endif
|
||||
super.init()
|
||||
|
||||
ghostty.delegate = self
|
||||
@@ -541,8 +574,9 @@ class AppDelegate: NSObject,
|
||||
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
|
||||
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
|
||||
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
|
||||
self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
|
||||
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
|
||||
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
|
||||
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
|
||||
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
|
||||
@@ -588,6 +622,8 @@ class AppDelegate: NSObject,
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
|
||||
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
|
||||
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
|
||||
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
|
||||
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
|
||||
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)
|
||||
|
||||
@@ -609,6 +645,7 @@ class AppDelegate: NSObject,
|
||||
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
|
||||
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
|
||||
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
|
||||
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
|
||||
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
|
||||
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
|
||||
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
|
||||
@@ -657,6 +694,18 @@ class AppDelegate: NSObject,
|
||||
}
|
||||
|
||||
private func localEventKeyDown(_ event: NSEvent) -> NSEvent? {
|
||||
// If the tab overview is visible and escape is pressed, close it.
|
||||
// This can't POSSIBLY be right and is probably a FirstResponder problem
|
||||
// that we should handle elsewhere in our program. But this works and it
|
||||
// is guarded by the tab overview currently showing.
|
||||
if event.keyCode == 0x35, // Escape key
|
||||
let window = NSApp.keyWindow,
|
||||
let tabGroup = window.tabGroup,
|
||||
tabGroup.isOverviewVisible {
|
||||
window.toggleTabOverview(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have a main window then we don't process any of the keys
|
||||
// because we let it capture and propagate.
|
||||
guard NSApp.mainWindow == nil else { return event }
|
||||
@@ -902,33 +951,8 @@ class AppDelegate: NSObject,
|
||||
var appIconName: String? = config.macosIcon.rawValue
|
||||
|
||||
switch (config.macosIcon) {
|
||||
case .official:
|
||||
// Discard saved icon name
|
||||
appIconName = nil
|
||||
break
|
||||
case .blueprint:
|
||||
appIcon = NSImage(named: "BlueprintImage")!
|
||||
|
||||
case .chalkboard:
|
||||
appIcon = NSImage(named: "ChalkboardImage")!
|
||||
|
||||
case .glass:
|
||||
appIcon = NSImage(named: "GlassImage")!
|
||||
|
||||
case .holographic:
|
||||
appIcon = NSImage(named: "HolographicImage")!
|
||||
|
||||
case .microchip:
|
||||
appIcon = NSImage(named: "MicrochipImage")!
|
||||
|
||||
case .paper:
|
||||
appIcon = NSImage(named: "PaperImage")!
|
||||
|
||||
case .retro:
|
||||
appIcon = NSImage(named: "RetroImage")!
|
||||
|
||||
case .xray:
|
||||
appIcon = NSImage(named: "XrayImage")!
|
||||
case let icon where icon.assetName != nil:
|
||||
appIcon = NSImage(named: icon.assetName!)!
|
||||
|
||||
case .custom:
|
||||
if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) {
|
||||
@@ -938,6 +962,7 @@ class AppDelegate: NSObject,
|
||||
appIcon = nil // Revert back to official icon if invalid location
|
||||
appIconName = nil // Discard saved icon name
|
||||
}
|
||||
|
||||
case .customStyle:
|
||||
// Discard saved icon name
|
||||
// if no valid colours were found
|
||||
@@ -953,10 +978,20 @@ class AppDelegate: NSObject,
|
||||
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
|
||||
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
|
||||
.joined(separator: "_")
|
||||
|
||||
default:
|
||||
// Discard saved icon name
|
||||
appIconName = nil
|
||||
}
|
||||
// Only change the icon if it has actually changed
|
||||
// from the current one
|
||||
guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else {
|
||||
|
||||
// Only change the icon if it has actually changed from the current one,
|
||||
// or if the app build has changed (e.g. after an update that reset the icon)
|
||||
let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon")
|
||||
let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild")
|
||||
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
|
||||
let buildChanged = cachedIconBuild != currentBuild
|
||||
|
||||
guard cachedIconName != appIconName || buildChanged else {
|
||||
#if DEBUG
|
||||
if appIcon == nil {
|
||||
await MainActor.run {
|
||||
@@ -973,14 +1008,16 @@ class AppDelegate: NSObject,
|
||||
let newIcon = appIcon
|
||||
|
||||
let appPath = Bundle.main.bundlePath
|
||||
NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: [])
|
||||
guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return }
|
||||
NSWorkspace.shared.noteFileSystemChanged(appPath)
|
||||
|
||||
await MainActor.run {
|
||||
self.appIcon = newIcon
|
||||
NSApplication.shared.applicationIconImage = newIcon
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon")
|
||||
UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild")
|
||||
}
|
||||
|
||||
//MARK: - Restorable State
|
||||
@@ -992,10 +1029,31 @@ class AppDelegate: NSObject,
|
||||
|
||||
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
|
||||
Self.logger.debug("application will save window state")
|
||||
|
||||
guard ghostty.config.windowSaveState != "never" else { return }
|
||||
|
||||
// Encode our quick terminal state if we have it.
|
||||
switch quickTerminalControllerState {
|
||||
case .initialized(let controller) where controller.restorable:
|
||||
let data = QuickTerminalRestorableState(from: controller)
|
||||
data.encode(with: coder)
|
||||
|
||||
case .pendingRestore(let state):
|
||||
state.encode(with: coder)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
|
||||
Self.logger.debug("application will restore window state")
|
||||
|
||||
// Decode our quick terminal state.
|
||||
if ghostty.config.windowSaveState != "never",
|
||||
let state = QuickTerminalRestorableState(coder: coder) {
|
||||
quickTerminalControllerState = .pendingRestore(state)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - UNUserNotificationCenterDelegate
|
||||
@@ -1269,6 +1327,16 @@ extension AppDelegate: NSMenuItemValidation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the quick terminal controller.
|
||||
private enum QuickTerminalState {
|
||||
/// Controller has not been initialized and has no pending restoration state.
|
||||
case uninitialized
|
||||
/// Restoration state is pending; controller will use this when first accessed.
|
||||
case pendingRestore(QuickTerminalRestorableState)
|
||||
/// Controller has been initialized.
|
||||
case initialized(QuickTerminalController)
|
||||
}
|
||||
|
||||
@globalActor
|
||||
fileprivate actor AppIconActor: GlobalActor {
|
||||
static let shared = AppIconActor()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
@@ -16,6 +16,7 @@
|
||||
<connections>
|
||||
<outlet property="menuAbout" destination="5kV-Vb-QxS" id="Y5y-UO-NK6"/>
|
||||
<outlet property="menuBringAllToFront" destination="LE2-aR-0XJ" id="AP9-oK-60V"/>
|
||||
<outlet property="menuChangeTabTitle" destination="iac-lh-Cl7" id="tId-v0-a3E"/>
|
||||
<outlet property="menuChangeTitle" destination="24I-xg-qIq" id="kg6-kT-jNL"/>
|
||||
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
|
||||
<outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
|
||||
@@ -46,6 +47,7 @@
|
||||
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
|
||||
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
|
||||
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
|
||||
<outlet property="menuReadonly" destination="xpe-ia-Yjw" id="MMT-Sl-AfD"/>
|
||||
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
|
||||
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
|
||||
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
|
||||
@@ -56,6 +58,7 @@
|
||||
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
|
||||
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
|
||||
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
|
||||
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
|
||||
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
|
||||
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
|
||||
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
|
||||
@@ -279,6 +282,19 @@
|
||||
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="2N8-Xz-RVc"/>
|
||||
<menuItem title="Use Selection for Find" id="TDN-42-Bu7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Jump to Selection" id="1rN-4k-Dz3">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="scrollToSelection:" target="-1" id="5gS-8h-Xm2"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
@@ -315,12 +331,24 @@
|
||||
<action selector="toggleCommandPalette:" target="-1" id="FcT-XD-gM1"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Change Title..." id="24I-xg-qIq">
|
||||
<menuItem title="Change Tab Title..." id="iac-lh-Cl7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="changeTabTitle:" target="-1" id="Jhl-9P-bMj"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Change Terminal Title..." id="24I-xg-qIq">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="changeTitle:" target="-1" id="XuL-QB-Q9l"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Terminal Read-only" id="xpe-ia-Yjw">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="toggleReadonly:" target="-1" id="Gqx-wT-K9v"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="Vkj-tP-dMZ"/>
|
||||
<menuItem title="Quick Terminal" id="1pv-LF-NBJ">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
|
||||
@@ -44,10 +44,7 @@ struct AboutView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
ghosttyIconImage()
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 128)
|
||||
CyclingIconView()
|
||||
|
||||
VStack(alignment: .center, spacing: 32) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
|
||||
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
/// A view that cycles through Ghostty's official icon variants.
|
||||
struct CyclingIconView: View {
|
||||
@State private var currentIcon: Ghostty.MacOSIcon = .official
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
private let icons: [Ghostty.MacOSIcon] = [
|
||||
.official,
|
||||
.blueprint,
|
||||
.chalkboard,
|
||||
.microchip,
|
||||
.glass,
|
||||
.holographic,
|
||||
.paper,
|
||||
.retro,
|
||||
.xray,
|
||||
]
|
||||
private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
iconView(for: currentIcon)
|
||||
.id(currentIcon)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.5), value: currentIcon)
|
||||
.frame(height: 128)
|
||||
.onReceive(timerPublisher.autoconnect()) { _ in
|
||||
if !isHovering {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
}
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
.onTapGesture {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
.help("macos-icon = \(currentIcon.rawValue)")
|
||||
.accessibilityLabel("Ghostty Application Icon")
|
||||
.accessibilityHint("Click to cycle through icon variants")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iconView(for icon: Ghostty.MacOSIcon) -> some View {
|
||||
let iconImage: Image = switch icon.assetName {
|
||||
case let assetName?: Image(assetName)
|
||||
case nil: ghosttyIconImage()
|
||||
}
|
||||
|
||||
iconImage
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
|
||||
private func advanceToNextIcon() {
|
||||
let currentIndex = icons.firstIndex(of: currentIcon) ?? 0
|
||||
let nextIndex = icons.indexWrapping(after: currentIndex)
|
||||
currentIcon = icons[nextIndex]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppIntents
|
||||
import Cocoa
|
||||
|
||||
// MARK: AppEntity
|
||||
|
||||
@@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery {
|
||||
|
||||
@MainActor
|
||||
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
|
||||
let commands = appDelegate.ghostty.config.commandPaletteEntries
|
||||
|
||||
// Extract unique terminal IDs to avoid fetching duplicates
|
||||
let terminalIds = Set(identifiers.map(\.terminalId))
|
||||
let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
|
||||
|
||||
// Build a cache of terminals and their available commands
|
||||
// This avoids repeated command fetching for the same terminal
|
||||
typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
|
||||
let commandMap: [TerminalEntity.ID: Tuple] =
|
||||
// Build a lookup from terminal ID to terminal entity
|
||||
let terminalMap: [TerminalEntity.ID: TerminalEntity] =
|
||||
terminals.reduce(into: [:]) { result, terminal in
|
||||
guard let commands = try? terminal.surfaceModel?.commands() else { return }
|
||||
result[terminal.id] = (terminal: terminal, commands: commands)
|
||||
result[terminal.id] = terminal
|
||||
}
|
||||
|
||||
|
||||
// Map each identifier to its corresponding CommandEntity. If a command doesn't
|
||||
// exist it maps to nil and is removed via compactMap.
|
||||
return identifiers.compactMap { id in
|
||||
guard let (terminal, commands) = commandMap[id.terminalId],
|
||||
guard let terminal = terminalMap[id.terminalId],
|
||||
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery {
|
||||
|
||||
@MainActor
|
||||
func suggestedEntities() async throws -> [CommandEntity] {
|
||||
guard let terminal = commandPaletteIntent?.terminal,
|
||||
let surface = terminal.surfaceModel else { return [] }
|
||||
return try surface.commands().map { CommandEntity($0, for: terminal) }
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate,
|
||||
let terminal = commandPaletteIntent?.terminal else { return [] }
|
||||
return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommandOption: Identifiable, Hashable {
|
||||
/// Unique identifier for this option.
|
||||
let id = UUID()
|
||||
/// The primary text displayed for this command.
|
||||
let title: String
|
||||
/// Secondary text displayed below the title.
|
||||
let subtitle: String?
|
||||
/// Tooltip text shown on hover.
|
||||
let description: String?
|
||||
/// Keyboard shortcut symbols to display.
|
||||
let symbols: [String]?
|
||||
/// SF Symbol name for the leading icon.
|
||||
let leadingIcon: String?
|
||||
/// Color for the leading indicator circle.
|
||||
let leadingColor: Color?
|
||||
/// Badge text displayed as a pill.
|
||||
let badge: String?
|
||||
/// Whether to visually emphasize this option.
|
||||
let emphasis: Bool
|
||||
/// Sort key for stable ordering when titles are equal.
|
||||
let sortKey: AnySortKey?
|
||||
/// The action to perform when this option is selected.
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
description: String? = nil,
|
||||
symbols: [String]? = nil,
|
||||
leadingIcon: String? = nil,
|
||||
leadingColor: Color? = nil,
|
||||
badge: String? = nil,
|
||||
emphasis: Bool = false,
|
||||
sortKey: AnySortKey? = nil,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.description = description
|
||||
self.symbols = symbols
|
||||
self.leadingIcon = leadingIcon
|
||||
self.leadingColor = leadingColor
|
||||
self.badge = badge
|
||||
self.emphasis = emphasis
|
||||
self.sortKey = sortKey
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@@ -47,12 +67,24 @@ struct CommandPaletteView: View {
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
// The options that we should show, taking into account any filtering from
|
||||
// the query.
|
||||
// the query. Options with matching leadingColor are ranked higher.
|
||||
var filteredOptions: [CommandOption] {
|
||||
if query.isEmpty {
|
||||
return options
|
||||
} else {
|
||||
return options.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
||||
// Filter by title/subtitle match OR color match
|
||||
let filtered = options.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(query) ||
|
||||
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
|
||||
colorMatchScore(for: $0.leadingColor, query: query) > 0
|
||||
}
|
||||
|
||||
// Sort by color match score (higher scores first), then maintain original order
|
||||
return filtered.sorted { a, b in
|
||||
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
|
||||
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
|
||||
return scoreA > scoreB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +200,32 @@ struct CommandPaletteView: View {
|
||||
isTextFieldFocused = isPresented
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
|
||||
/// Returns 0 if no color name in the query matches, or if the color is nil.
|
||||
private func colorMatchScore(for color: Color?, query: String) -> Double {
|
||||
guard let color = color else { return 0 }
|
||||
|
||||
let queryLower = query.lowercased()
|
||||
let nsColor = NSColor(color)
|
||||
|
||||
var bestScore: Double = 0
|
||||
for name in NSColor.colorNames {
|
||||
guard queryLower.contains(name),
|
||||
let systemColor = NSColor(named: name) else { continue }
|
||||
|
||||
let distance = nsColor.distance(to: systemColor)
|
||||
// Max distance in weighted RGB space is ~3.0, so normalize and invert
|
||||
// Use a threshold to determine "close enough" matches
|
||||
let maxDistance: Double = 1.5
|
||||
if distance < maxDistance {
|
||||
let score = 1.0 - (distance / maxDistance)
|
||||
bestScore = max(bestScore, score)
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field for building the query for the command palette.
|
||||
@@ -283,14 +341,28 @@ fileprivate struct CommandRow: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
if let color = option.leadingColor {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
if let icon = option.leadingIcon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
|
||||
Text(option.title)
|
||||
.fontWeight(option.emphasis ? .medium : .regular)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(option.title)
|
||||
.fontWeight(option.emphasis ? .medium : .regular)
|
||||
|
||||
if let subtitle = option.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
@@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View {
|
||||
/// The callback when an action is submitted.
|
||||
var onAction: ((String) -> Void)
|
||||
|
||||
// The commands available to the command palette.
|
||||
private var commandOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
|
||||
// Add update command if an update is installable. This must always be the first so
|
||||
// it is at the top.
|
||||
if let updateViewModel, updateViewModel.state.isInstallable {
|
||||
// We override the update available one only because we want to properly
|
||||
// convey it'll go all the way through.
|
||||
let title: String
|
||||
if case .updateAvailable = updateViewModel.state {
|
||||
title = "Update Ghostty and Restart"
|
||||
} else {
|
||||
title = updateViewModel.text
|
||||
}
|
||||
|
||||
options.append(CommandOption(
|
||||
title: title,
|
||||
description: updateViewModel.description,
|
||||
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
|
||||
badge: updateViewModel.badge,
|
||||
emphasis: true
|
||||
) {
|
||||
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
|
||||
})
|
||||
}
|
||||
|
||||
// Add cancel/skip update command if the update is installable
|
||||
if let updateViewModel, updateViewModel.state.isInstallable {
|
||||
options.append(CommandOption(
|
||||
title: "Cancel or Skip Update",
|
||||
description: "Dismiss the current update process"
|
||||
) {
|
||||
updateViewModel.state.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
// Add terminal commands
|
||||
guard let surface = surfaceView.surfaceModel else { return options }
|
||||
do {
|
||||
let terminalCommands = try surface.commands().map { c in
|
||||
return CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
options.append(contentsOf: terminalCommands)
|
||||
} catch {
|
||||
return options
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isPresented {
|
||||
@@ -96,13 +39,8 @@ struct TerminalCommandPaletteView: View {
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
||||
}
|
||||
.transition(
|
||||
.move(edge: .top)
|
||||
.combined(with: .opacity)
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented)
|
||||
.onChange(of: isPresented) { newValue in
|
||||
// When the command palette disappears we need to send focus back to the
|
||||
// surface view we were overlaid on top of. There's probably a better way
|
||||
@@ -116,6 +54,116 @@ struct TerminalCommandPaletteView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All commands available in the command palette, combining update and terminal options.
|
||||
private var commandOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
// Updates always appear first
|
||||
options.append(contentsOf: updateOptions)
|
||||
|
||||
// Sort the rest. We replace ":" with a character that sorts before space
|
||||
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
|
||||
// for stable ordering when titles are equal.
|
||||
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
|
||||
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
|
||||
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
|
||||
let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized)
|
||||
if comparison != .orderedSame {
|
||||
return comparison == .orderedAscending
|
||||
}
|
||||
// Tie-breaker: use sortKey if both have one
|
||||
if let aSortKey = a.sortKey, let bSortKey = b.sortKey {
|
||||
return aSortKey < bSortKey
|
||||
}
|
||||
return false
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
/// Commands for installing or canceling available updates.
|
||||
private var updateOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
|
||||
guard let updateViewModel, updateViewModel.state.isInstallable else {
|
||||
return options
|
||||
}
|
||||
|
||||
// We override the update available one only because we want to properly
|
||||
// convey it'll go all the way through.
|
||||
let title: String
|
||||
if case .updateAvailable = updateViewModel.state {
|
||||
title = "Update Ghostty and Restart"
|
||||
} else {
|
||||
title = updateViewModel.text
|
||||
}
|
||||
|
||||
options.append(CommandOption(
|
||||
title: title,
|
||||
description: updateViewModel.description,
|
||||
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
|
||||
badge: updateViewModel.badge,
|
||||
emphasis: true
|
||||
) {
|
||||
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
|
||||
})
|
||||
|
||||
options.append(CommandOption(
|
||||
title: "Cancel or Skip Update",
|
||||
description: "Dismiss the current update process"
|
||||
) {
|
||||
updateViewModel.state.cancel()
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/// Custom commands from the command-palette-entry configuration.
|
||||
private var terminalOptions: [CommandOption] {
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
|
||||
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
|
||||
CommandOption(
|
||||
title: c.title,
|
||||
description: c.description
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands for jumping to other terminal surfaces.
|
||||
private var jumpOptions: [CommandOption] {
|
||||
TerminalController.all.flatMap { controller -> [CommandOption] in
|
||||
guard let window = controller.window else { return [] }
|
||||
|
||||
let color = (window as? TerminalWindow)?.tabColor
|
||||
let displayColor = color != TerminalTabColor.none ? color : nil
|
||||
|
||||
return controller.surfaceTree.map { surface in
|
||||
let title = surface.title.isEmpty ? window.title : surface.title
|
||||
let displayTitle = title.isEmpty ? "Untitled" : title
|
||||
let pwd = surface.pwd?.abbreviatedPath
|
||||
let subtitle: String? = if let pwd, !displayTitle.contains(pwd) {
|
||||
pwd
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return CommandOption(
|
||||
title: "Focus: \(displayTitle)",
|
||||
subtitle: subtitle,
|
||||
leadingIcon: "rectangle.on.rectangle",
|
||||
leadingColor: displayColor?.displayColor.map { Color($0) },
|
||||
sortKey: AnySortKey(ObjectIdentifier(surface))
|
||||
) {
|
||||
NotificationCenter.default.post(
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// This is done to ensure that the given view is in the responder chain.
|
||||
|
||||
@@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
private var previousActiveSpace: CGSSpace? = nil
|
||||
|
||||
/// Cache for per-screen window state.
|
||||
private let screenStateCache = QuickTerminalScreenStateCache()
|
||||
let screenStateCache: QuickTerminalScreenStateCache
|
||||
|
||||
/// Non-nil if we have hidden dock state.
|
||||
private var hiddenDock: HiddenDock? = nil
|
||||
@@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController {
|
||||
|
||||
/// Tracks if we're currently handling a manual resize to prevent recursion
|
||||
private var isHandlingResize: Bool = false
|
||||
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
let restorable: Bool
|
||||
private var restorationState: QuickTerminalRestorableState?
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
position: QuickTerminalPosition = .top,
|
||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
restorationState: QuickTerminalRestorableState? = nil,
|
||||
) {
|
||||
self.position = position
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
// The window we manage is not restorable if we've specified a command
|
||||
// to execute. We do this because the restored window is meaningless at the
|
||||
// time of writing this: it'd just restore to a shell in the same directory
|
||||
// as the script. We may want to revisit this behavior when we have scrollback
|
||||
// restoration.
|
||||
restorable = (base?.command ?? "") == ""
|
||||
self.restorationState = restorationState
|
||||
self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:])
|
||||
// Important detail here: we initialize with an empty surface tree so
|
||||
// that we don't start a terminal process. This gets started when the
|
||||
// first terminal is shown in `animateIn`.
|
||||
@@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// window close so we can animate out.
|
||||
window.delegate = self
|
||||
|
||||
// The quick window is not restorable (yet!). "Yet" because in theory we can
|
||||
// make this restorable, but it isn't currently implemented.
|
||||
// The quick window is restored by `screenStateCache`.
|
||||
// We disable this for better control
|
||||
window.isRestorable = false
|
||||
|
||||
// Setup our configured appearance that we support.
|
||||
@@ -125,11 +137,11 @@ class QuickTerminalController: BaseTerminalController {
|
||||
}
|
||||
|
||||
// Setup our content
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self
|
||||
))
|
||||
)
|
||||
|
||||
// Clear out our frame at this point, the fixup from above is complete.
|
||||
if let qtWindow = window as? QuickTerminalWindow {
|
||||
@@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// animate out.
|
||||
if surfaceTree.isEmpty,
|
||||
let ghostty_app = ghostty.app {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
|
||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||
surfaceTree = SplitTree(view: view)
|
||||
focusedSurface = view
|
||||
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
|
||||
surfaceTree = tree
|
||||
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
|
||||
focusedSurface = view
|
||||
// Add a short delay to check if the correct surface is focused.
|
||||
// Each SurfaceWrapper defaults its FocusedValue to itself; without this delay,
|
||||
// the tree often focuses the first surface instead of the intended one.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
if !view.focused {
|
||||
self.focusedSurface = view
|
||||
self.makeWindowKey(window)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
|
||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||
surfaceTree = SplitTree(view: view)
|
||||
focusedSurface = view
|
||||
}
|
||||
}
|
||||
|
||||
// Animate the window in
|
||||
animateWindowIn(window: window, from: position)
|
||||
// Clear the restoration state after first use
|
||||
restorationState = nil
|
||||
}
|
||||
|
||||
func animateOut() {
|
||||
@@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController {
|
||||
animateWindowOut(window: window, to: position)
|
||||
}
|
||||
|
||||
func saveScreenState(exitFullscreen: Bool) {
|
||||
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
||||
// we have th correct window.frame for the save state below.
|
||||
if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen {
|
||||
fullscreenStyle.exit()
|
||||
}
|
||||
guard let window else { return }
|
||||
// Save the current window frame before animating out. This preserves
|
||||
// the user's preferred window size and position for when the quick
|
||||
// terminal is reactivated with a new surface. Without this, SwiftUI
|
||||
// would reset the window to its minimum content size.
|
||||
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
|
||||
screenStateCache.save(frame: window.frame, for: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
||||
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
||||
|
||||
@@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
}
|
||||
|
||||
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
|
||||
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
||||
// we have th correct window.frame for the save state below.
|
||||
if let fullscreenStyle, fullscreenStyle.isFullscreen {
|
||||
fullscreenStyle.exit()
|
||||
}
|
||||
|
||||
// Save the current window frame before animating out. This preserves
|
||||
// the user's preferred window size and position for when the quick
|
||||
// terminal is reactivated with a new surface. Without this, SwiftUI
|
||||
// would reset the window to its minimum content size.
|
||||
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
|
||||
screenStateCache.save(frame: window.frame, for: screen)
|
||||
}
|
||||
saveScreenState(exitFullscreen: true)
|
||||
|
||||
// If we hid the dock then we unhide it.
|
||||
hiddenDock = nil
|
||||
@@ -563,9 +596,10 @@ class QuickTerminalController: BaseTerminalController {
|
||||
})
|
||||
}
|
||||
|
||||
private func syncAppearance() {
|
||||
override func syncAppearance() {
|
||||
guard let window else { return }
|
||||
|
||||
defer { updateColorSchemeForSurfaceTree() }
|
||||
// Change the collection behavior of the window depending on the configuration.
|
||||
window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior
|
||||
|
||||
@@ -574,7 +608,8 @@ class QuickTerminalController: BaseTerminalController {
|
||||
guard window.isVisible else { return }
|
||||
|
||||
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
||||
if (self.derivedConfig.backgroundOpacity < 1) {
|
||||
// Also check if the user has overridden transparency to be fully opaque.
|
||||
if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) {
|
||||
window.isOpaque = false
|
||||
|
||||
// This is weird, but we don't use ".clear" because this creates a look that
|
||||
@@ -582,7 +617,9 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// Terminal.app more easily.
|
||||
window.backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
if !derivedConfig.backgroundBlur.isGlassStyle {
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
}
|
||||
} else {
|
||||
window.isOpaque = true
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
@@ -687,6 +724,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
|
||||
let quickTerminalSize: QuickTerminalSize
|
||||
let backgroundOpacity: Double
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
|
||||
init() {
|
||||
self.quickTerminalScreen = .main
|
||||
@@ -695,6 +733,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = .move
|
||||
self.quickTerminalSize = QuickTerminalSize()
|
||||
self.backgroundOpacity = 1.0
|
||||
self.backgroundBlur = .disabled
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
@@ -704,6 +743,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
|
||||
self.quickTerminalSize = config.quickTerminalSize
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Cocoa
|
||||
|
||||
struct QuickTerminalRestorableState: TerminalRestorable {
|
||||
static var version: Int { 1 }
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
let screenStateEntries: QuickTerminalScreenStateCache.Entries
|
||||
|
||||
init(from controller: QuickTerminalController) {
|
||||
controller.saveScreenState(exitFullscreen: true)
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
self.screenStateEntries = controller.screenStateCache.stateByDisplay
|
||||
}
|
||||
|
||||
init(copy other: QuickTerminalRestorableState) {
|
||||
self = other
|
||||
}
|
||||
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
return config
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import Cocoa
|
||||
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
|
||||
/// to survive NSScreen garbage collection and automatically prunes stale entries.
|
||||
class QuickTerminalScreenStateCache {
|
||||
typealias Entries = [UUID: DisplayEntry]
|
||||
|
||||
/// The maximum number of saved screen states we retain. This is to avoid some kind of
|
||||
/// pathological memory growth in case we get our screen state serializing wrong. I don't
|
||||
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
|
||||
@@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache {
|
||||
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
||||
|
||||
/// Keyed by display UUID to survive NSScreen garbage collection.
|
||||
private var stateByDisplay: [UUID: DisplayEntry] = [:]
|
||||
|
||||
init() {
|
||||
private(set) var stateByDisplay: Entries = [:]
|
||||
|
||||
init(stateByDisplay: Entries = [:]) {
|
||||
self.stateByDisplay = stateByDisplay
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(onScreensChanged(_:)),
|
||||
@@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DisplayEntry {
|
||||
struct DisplayEntry: Codable {
|
||||
var frame: NSRect
|
||||
var screenSize: CGSize
|
||||
var scale: CGFloat
|
||||
|
||||
@@ -121,10 +121,10 @@ extension SplitTree {
|
||||
|
||||
/// Insert a new view at the given view point by creating a split in the given direction.
|
||||
/// This will always reset the zoomed state of the tree.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
return .init(
|
||||
root: try root.insert(view: view, at: at, direction: direction),
|
||||
root: try root.inserting(view: view, at: at, direction: direction),
|
||||
zoomed: nil)
|
||||
}
|
||||
/// Find a node containing a view with the specified ID.
|
||||
@@ -137,7 +137,7 @@ extension SplitTree {
|
||||
|
||||
/// Remove a node from the tree. If the node being removed is part of a split,
|
||||
/// the sibling node takes the place of the parent split.
|
||||
func remove(_ target: Node) -> Self {
|
||||
func removing(_ target: Node) -> Self {
|
||||
guard let root else { return self }
|
||||
|
||||
// If we're removing the root itself, return an empty tree
|
||||
@@ -155,7 +155,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
/// Replace a node in the tree with a new node.
|
||||
func replace(node: Node, with newNode: Node) throws -> Self {
|
||||
func replacing(node: Node, with newNode: Node) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
// Get the path to the node we want to replace
|
||||
@@ -164,7 +164,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
// Replace the node
|
||||
let newRoot = try root.replaceNode(at: path, with: newNode)
|
||||
let newRoot = try root.replacingNode(at: path, with: newNode)
|
||||
|
||||
// Update zoomed if it was the replaced node
|
||||
let newZoomed = (zoomed == node) ? newNode : zoomed
|
||||
@@ -232,7 +232,7 @@ extension SplitTree {
|
||||
|
||||
/// Equalize all splits in the tree so that each split's ratio is based on the
|
||||
/// relative weight (number of leaves) of its children.
|
||||
func equalize() -> Self {
|
||||
func equalized() -> Self {
|
||||
guard let root else { return self }
|
||||
let newRoot = root.equalize()
|
||||
return .init(root: newRoot, zoomed: zoomed)
|
||||
@@ -255,7 +255,7 @@ extension SplitTree {
|
||||
/// - bounds: The bounds used to construct the spatial tree representation
|
||||
/// - Returns: A new SplitTree with the adjusted split ratios
|
||||
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
|
||||
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
|
||||
func resizing(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
// Find the path to the target node
|
||||
@@ -327,7 +327,7 @@ extension SplitTree {
|
||||
)
|
||||
|
||||
// Replace the split node with the new one
|
||||
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
|
||||
let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit))
|
||||
return .init(root: newRoot, zoomed: nil)
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ extension SplitTree.Node {
|
||||
///
|
||||
/// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
|
||||
/// maybe throw instead but at the moment we just do nothing.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
// Get the path to our insertion point. If it doesn't exist we do
|
||||
// nothing.
|
||||
guard let path = path(to: .leaf(view: at)) else {
|
||||
@@ -544,11 +544,11 @@ extension SplitTree.Node {
|
||||
))
|
||||
|
||||
// Replace the node at the path with the new split
|
||||
return try replaceNode(at: path, with: newSplit)
|
||||
return try replacingNode(at: path, with: newSplit)
|
||||
}
|
||||
|
||||
/// Helper function to replace a node at the given path from the root
|
||||
func replaceNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
func replacingNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
// If path is empty, replace the root
|
||||
if path.isEmpty {
|
||||
return newNode
|
||||
@@ -635,7 +635,7 @@ extension SplitTree.Node {
|
||||
/// Resize a split node to the specified ratio.
|
||||
/// For leaf nodes, this returns the node unchanged.
|
||||
/// For split nodes, this creates a new split with the updated ratio.
|
||||
func resize(to ratio: Double) -> Self {
|
||||
func resizing(to ratio: Double) -> Self {
|
||||
switch self {
|
||||
case .leaf:
|
||||
// Leaf nodes don't have a ratio to resize
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single operation within the split tree.
|
||||
///
|
||||
/// Rather than binding the split tree (which is immutable), any mutable operations are
|
||||
/// exposed via this enum to the embedder to handle.
|
||||
enum TerminalSplitOperation {
|
||||
case resize(Resize)
|
||||
case drop(Drop)
|
||||
|
||||
struct Resize {
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
let ratio: Double
|
||||
}
|
||||
|
||||
struct Drop {
|
||||
/// The surface being dragged.
|
||||
let payload: Ghostty.SurfaceView
|
||||
|
||||
/// The surface it was dragged onto
|
||||
let destination: Ghostty.SurfaceView
|
||||
|
||||
/// The zone it was dropped to determine how to split the destination.
|
||||
let zone: TerminalSplitDropZone
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitTreeView: View {
|
||||
let tree: SplitTree<Ghostty.SurfaceView>
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
if let node = tree.zoomed ?? tree.root {
|
||||
TerminalSplitSubtreeView(
|
||||
node: node,
|
||||
isRoot: node == tree.root,
|
||||
onResize: onResize)
|
||||
action: action)
|
||||
// This is necessary because we can't rely on SwiftUI's implicit
|
||||
// structural identity to detect changes to this view. Due to
|
||||
// the tree structure of splits it could result in bad behaviors.
|
||||
@@ -19,21 +44,17 @@ struct TerminalSplitTreeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitSubtreeView: View {
|
||||
fileprivate struct TerminalSplitSubtreeView: View {
|
||||
@EnvironmentObject var ghostty: Ghostty.App
|
||||
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
var isRoot: Bool = false
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
switch (node) {
|
||||
case .leaf(let leafView):
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: leafView,
|
||||
isSplit: !isRoot)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
|
||||
|
||||
case .split(let split):
|
||||
let splitViewDirection: SplitViewDirection = switch (split.direction) {
|
||||
@@ -46,15 +67,15 @@ struct TerminalSplitSubtreeView: View {
|
||||
.init(get: {
|
||||
CGFloat(split.ratio)
|
||||
}, set: {
|
||||
onResize(node, $0)
|
||||
action(.resize(.init(node: node, ratio: $0)))
|
||||
}),
|
||||
dividerColor: ghostty.config.splitDividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
left: {
|
||||
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.left, action: action)
|
||||
},
|
||||
right: {
|
||||
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.right, action: action)
|
||||
},
|
||||
onEqualize: {
|
||||
guard let surface = node.leftmostLeaf().surface else { return }
|
||||
@@ -64,3 +85,173 @@ struct TerminalSplitSubtreeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct TerminalSplitLeaf: View {
|
||||
let surfaceView: Ghostty.SurfaceView
|
||||
let isSplit: Bool
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
@State private var dropState: DropState = .idle
|
||||
@State private var isSelfDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: surfaceView,
|
||||
isSplit: isSplit)
|
||||
.background {
|
||||
// If we're dragging ourself, we hide the entire drop zone. This makes
|
||||
// it so that a released drop animates back to its source properly
|
||||
// so it is a proper invalid drop zone.
|
||||
if !isSelfDragging {
|
||||
Color.clear
|
||||
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
|
||||
dropState: $dropState,
|
||||
viewSize: geometry.size,
|
||||
destinationSurface: surfaceView,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if !isSelfDragging, case .dropping(let zone) = dropState {
|
||||
zone.overlay(in: geometry)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in
|
||||
isSelfDragging = value == surfaceView.id
|
||||
if isSelfDragging {
|
||||
dropState = .idle
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropState: Equatable {
|
||||
case idle
|
||||
case dropping(TerminalSplitDropZone)
|
||||
}
|
||||
|
||||
private struct SplitDropDelegate: DropDelegate {
|
||||
@Binding var dropState: DropState
|
||||
let viewSize: CGSize
|
||||
let destinationSurface: Ghostty.SurfaceView
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
info.hasItemsConforming(to: [.ghosttySurfaceId])
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
// For some reason dropUpdated is sent after performDrop is called
|
||||
// and we don't want to reset our drop zone to show it so we have
|
||||
// to guard on the state here.
|
||||
guard case .dropping = dropState else { return DropProposal(operation: .forbidden) }
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
dropState = .idle
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
|
||||
dropState = .idle
|
||||
|
||||
// Load the dropped surface asynchronously using Transferable
|
||||
let providers = info.itemProviders(for: [.ghosttySurfaceId])
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
// Capture action before the async closure
|
||||
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
|
||||
switch result {
|
||||
case .success(let sourceSurface):
|
||||
DispatchQueue.main.async {
|
||||
// Don't allow dropping on self
|
||||
guard let destinationSurface else { return }
|
||||
guard sourceSurface !== destinationSurface else { return }
|
||||
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
|
||||
}
|
||||
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalSplitDropZone: String, Equatable {
|
||||
case top
|
||||
case bottom
|
||||
case left
|
||||
case right
|
||||
|
||||
/// Determines which drop zone the cursor is in based on proximity to edges.
|
||||
///
|
||||
/// Divides the view into four triangular regions by drawing diagonals from
|
||||
/// corner to corner. The drop zone is determined by which edge the cursor
|
||||
/// is closest to, creating natural triangular hit regions for each side.
|
||||
static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone {
|
||||
let relX = point.x / size.width
|
||||
let relY = point.y / size.height
|
||||
|
||||
let distToLeft = relX
|
||||
let distToRight = 1 - relX
|
||||
let distToTop = relY
|
||||
let distToBottom = 1 - relY
|
||||
|
||||
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
|
||||
|
||||
if minDist == distToLeft { return .left }
|
||||
if minDist == distToRight { return .right }
|
||||
if minDist == distToTop { return .top }
|
||||
return .bottom
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func overlay(in geometry: GeometryProxy) -> some View {
|
||||
let overlayColor = Color.accentColor.opacity(0.3)
|
||||
|
||||
switch self {
|
||||
case .top:
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .bottom:
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
}
|
||||
case .left:
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .right:
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,27 @@ class BaseTerminalController: NSWindowController,
|
||||
/// The previous frame information from the window
|
||||
private var savedFrame: SavedFrame? = nil
|
||||
|
||||
/// Cache previously applied appearance to avoid unnecessary updates
|
||||
private var appliedColorScheme: ghostty_color_scheme_e?
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
/// Track whether background is forced opaque (true) or using config transparency (false)
|
||||
var isBackgroundOpaque: Bool = false
|
||||
|
||||
/// The cancellables related to our focused surface.
|
||||
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// An override title for the tab/window set by the user via prompt_tab_title.
|
||||
/// When set, this takes precedence over the computed title from the terminal.
|
||||
var titleOverride: String? = nil {
|
||||
didSet { applyTitleToWindow() }
|
||||
}
|
||||
|
||||
/// The last computed title from the focused surface (without the override).
|
||||
private var lastComputedTitle: String = "👻"
|
||||
|
||||
/// The time that undo/redo operations that contain running ptys are valid for.
|
||||
var undoExpiration: Duration {
|
||||
ghostty.config.undoTimeout
|
||||
@@ -180,6 +195,16 @@ class BaseTerminalController: NSWindowController,
|
||||
selector: #selector(ghosttyDidResizeSplit(_:)),
|
||||
name: Ghostty.Notification.didResizeSplit,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidPresentTerminal(_:)),
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttySurfaceDragEndedNoTarget(_:)),
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: nil)
|
||||
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
@@ -215,7 +240,7 @@ class BaseTerminalController: NSWindowController,
|
||||
// Do the split
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.insert(
|
||||
newTree = try surfaceTree.inserting(
|
||||
view: newView,
|
||||
at: oldView,
|
||||
direction: direction)
|
||||
@@ -322,6 +347,37 @@ class BaseTerminalController: NSWindowController,
|
||||
self.alert = alert
|
||||
}
|
||||
|
||||
/// Prompt the user to change the tab/window title.
|
||||
func promptTabTitle() {
|
||||
guard let window else { return }
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Change Tab Title"
|
||||
alert.informativeText = "Leave blank to restore the default."
|
||||
alert.alertStyle = .informational
|
||||
|
||||
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24))
|
||||
textField.stringValue = titleOverride ?? window.title
|
||||
alert.accessoryView = textField
|
||||
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
alert.window.initialFirstResponder = textField
|
||||
|
||||
alert.beginSheetModal(for: window) { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
|
||||
let newTitle = textField.stringValue
|
||||
if newTitle.isEmpty {
|
||||
self.titleOverride = nil
|
||||
} else {
|
||||
self.titleOverride = newTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a surface from a view.
|
||||
func closeSurface(
|
||||
_ view: Ghostty.SurfaceView,
|
||||
@@ -394,14 +450,14 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
surfaceTree.remove(node),
|
||||
surfaceTree.removing(node),
|
||||
moveFocusTo: nextFocus,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Close Terminal"
|
||||
)
|
||||
}
|
||||
|
||||
private func replaceSurfaceTree(
|
||||
func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
@@ -415,33 +471,33 @@ class BaseTerminalController: NSWindowController,
|
||||
Ghostty.moveFocus(to: newView, from: oldView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager {
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
guard let undoManager else { return }
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +614,7 @@ class BaseTerminalController: NSWindowController,
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Equalize the splits
|
||||
surfaceTree = surfaceTree.equalize()
|
||||
surfaceTree = surfaceTree.equalized()
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
|
||||
@@ -578,9 +634,14 @@ class BaseTerminalController: NSWindowController,
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the zoomed state for this surface tree.
|
||||
if surfaceTree.zoomed != nil {
|
||||
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
|
||||
if derivedConfig.splitPreserveZoom.contains(.navigation) {
|
||||
surfaceTree = SplitTree(
|
||||
root: surfaceTree.root,
|
||||
zoomed: surfaceTree.root?.node(view: nextSurface))
|
||||
} else {
|
||||
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Move focus to the next surface
|
||||
@@ -643,12 +704,64 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
// Perform the resize using the new SplitTree resize method
|
||||
do {
|
||||
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to resize split: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidPresentTerminal(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Bring the window to front and focus the surface.
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
|
||||
// We use a small delay to ensure this runs after any UI cleanup
|
||||
// (e.g., command palette restoring focus to its original surface).
|
||||
Ghostty.moveFocus(to: target)
|
||||
Ghostty.moveFocus(to: target, delay: 0.1)
|
||||
|
||||
// Show a brief highlight to help the user locate the presented terminal.
|
||||
target.highlight()
|
||||
}
|
||||
|
||||
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// If our tree isn't split, then we never create a new window, because
|
||||
// it is already a single split.
|
||||
guard surfaceTree.isSplit else { return }
|
||||
|
||||
// If we are removing our focused surface then we move it. We need to
|
||||
// keep track of our old one so undo sends focus back to the right place.
|
||||
let oldFocusedSurface = focusedSurface
|
||||
if focusedSurface == target {
|
||||
focusedSurface = findNextFocusTargetAfterClosing(node: targetNode)
|
||||
}
|
||||
|
||||
// Remove the surface from our tree
|
||||
let removedTree = surfaceTree.removing(targetNode)
|
||||
|
||||
// Create a new tree with the dragged surface and open a new window
|
||||
let newTree = SplitTree<Ghostty.SurfaceView>(view: target)
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface)
|
||||
_ = TerminalController.newWindow(
|
||||
ghostty,
|
||||
tree: newTree,
|
||||
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
|
||||
confirmUndo: false)
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
||||
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
||||
@@ -715,10 +828,21 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
private func titleDidChange(to: String) {
|
||||
lastComputedTitle = to
|
||||
applyTitleToWindow()
|
||||
}
|
||||
|
||||
private func applyTitleToWindow() {
|
||||
guard let window else { return }
|
||||
|
||||
// Set the main window title
|
||||
window.title = to
|
||||
if let titleOverride {
|
||||
window.title = computeTitle(
|
||||
title: titleOverride,
|
||||
bell: focusedSurface?.bell ?? false)
|
||||
return
|
||||
}
|
||||
|
||||
window.title = lastComputedTitle
|
||||
}
|
||||
|
||||
func pwdDidChange(to: URL?) {
|
||||
@@ -742,14 +866,101 @@ class BaseTerminalController: NSWindowController,
|
||||
self.window?.contentResizeIncrements = to
|
||||
}
|
||||
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resize(to: newRatio)
|
||||
func performSplitAction(_ action: TerminalSplitOperation) {
|
||||
switch action {
|
||||
case .resize(let resize):
|
||||
splitDidResize(node: resize.node, to: resize.ratio)
|
||||
case .drop(let drop):
|
||||
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resizing(to: newRatio)
|
||||
do {
|
||||
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
|
||||
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidDrop(
|
||||
source: Ghostty.SurfaceView,
|
||||
destination: Ghostty.SurfaceView,
|
||||
zone: TerminalSplitDropZone
|
||||
) {
|
||||
// Map drop zone to split direction
|
||||
let direction: SplitTree<Ghostty.SurfaceView>.NewDirection = switch zone {
|
||||
case .top: .up
|
||||
case .bottom: .down
|
||||
case .left: .left
|
||||
case .right: .right
|
||||
}
|
||||
|
||||
// Check if source is in our tree
|
||||
if let sourceNode = surfaceTree.root?.node(view: source) {
|
||||
// Source is in our tree - same window move
|
||||
let treeWithoutSource = surfaceTree.removing(sourceNode)
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Move Split")
|
||||
return
|
||||
}
|
||||
|
||||
// Source is not in our tree - search other windows
|
||||
var sourceController: BaseTerminalController?
|
||||
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else { continue }
|
||||
guard controller !== self else { continue }
|
||||
if let node = controller.surfaceTree.root?.node(view: source) {
|
||||
sourceController = controller
|
||||
sourceNode = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let sourceController, let sourceNode else {
|
||||
Ghostty.logger.warning("source surface not found in any window during drop")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from source controller's tree and add it to our tree.
|
||||
// We do this first because if there is an error then we can
|
||||
// abort.
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
// Remove the node from the source.
|
||||
sourceController.removeSurfaceNode(sourceNode)
|
||||
|
||||
// Add in the surface to our tree
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface)
|
||||
}
|
||||
|
||||
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
|
||||
@@ -761,6 +972,35 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
|
||||
/// Toggle the background opacity between transparent and opaque states.
|
||||
/// Do nothing if the configured background-opacity is >= 1 (already opaque).
|
||||
/// Subclasses should override this to add platform-specific checks and sync appearance.
|
||||
func toggleBackgroundOpacity() {
|
||||
// Do nothing if config is already fully opaque
|
||||
guard ghostty.config.backgroundOpacity < 1 else { return }
|
||||
|
||||
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
|
||||
guard let window, !window.styleMask.contains(.fullScreen) else { return }
|
||||
|
||||
// Toggle between transparent and opaque
|
||||
isBackgroundOpaque.toggle()
|
||||
|
||||
// Update our appearance
|
||||
syncAppearance()
|
||||
}
|
||||
|
||||
/// Override this to resync any appearance related properties. This will be called automatically
|
||||
/// when certain window properties change that affect appearance. The list below should be updated
|
||||
/// as we add new things:
|
||||
///
|
||||
/// - ``toggleBackgroundOpacity``
|
||||
func syncAppearance() {
|
||||
// Purposely a no-op. This lets subclasses override this and we can call
|
||||
// it virtually from here.
|
||||
}
|
||||
|
||||
// MARK: Fullscreen
|
||||
|
||||
/// Toggle fullscreen for the given mode.
|
||||
@@ -821,6 +1061,9 @@ class BaseTerminalController: NSWindowController,
|
||||
} else {
|
||||
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
|
||||
}
|
||||
|
||||
// Always resync our appearance
|
||||
syncAppearance()
|
||||
}
|
||||
|
||||
// MARK: Clipboard Confirmation
|
||||
@@ -969,6 +1212,15 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
// If when we become key our first responder is the window itself, then we
|
||||
// want to move focus to our focused terminal surface. This works around
|
||||
// various weirdness with moving surfaces around.
|
||||
if let window, window.firstResponder == window, let focusedSurface {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// Becoming/losing key means we have to notify our surface(s) that we have focus
|
||||
// so things like cursors blink, pty events are sent, etc.
|
||||
self.syncFocusToSurfaceTree()
|
||||
@@ -1014,6 +1266,10 @@ class BaseTerminalController: NSWindowController,
|
||||
window.performClose(sender)
|
||||
}
|
||||
|
||||
@IBAction func changeTabTitle(_ sender: Any) {
|
||||
promptTabTitle()
|
||||
}
|
||||
|
||||
@IBAction func splitRight(_ sender: Any) {
|
||||
guard let surface = focusedSurface?.surface else { return }
|
||||
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT)
|
||||
@@ -1116,7 +1372,15 @@ class BaseTerminalController: NSWindowController,
|
||||
@IBAction func find(_ sender: Any) {
|
||||
focusedSurface?.find(sender)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func selectionForFind(_ sender: Any) {
|
||||
focusedSurface?.selectionForFind(sender)
|
||||
}
|
||||
|
||||
@IBAction func scrollToSelection(_ sender: Any) {
|
||||
focusedSurface?.scrollToSelection(sender)
|
||||
}
|
||||
|
||||
@IBAction func findNext(_ sender: Any) {
|
||||
focusedSurface?.findNext(sender)
|
||||
}
|
||||
@@ -1138,17 +1402,20 @@ class BaseTerminalController: NSWindowController,
|
||||
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
||||
let windowStepResize: Bool
|
||||
let focusFollowsMouse: Bool
|
||||
let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom
|
||||
|
||||
init() {
|
||||
self.macosTitlebarProxyIcon = .visible
|
||||
self.windowStepResize = false
|
||||
self.focusFollowsMouse = false
|
||||
self.splitPreserveZoom = .init()
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
||||
self.windowStepResize = config.windowStepResize
|
||||
self.focusFollowsMouse = config.focusFollowsMouse
|
||||
self.splitPreserveZoom = config.splitPreserveZoom
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1163,4 +1430,35 @@ extension BaseTerminalController: NSMenuItemValidation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Surface Color Scheme
|
||||
|
||||
/// Update the surface tree's color scheme only when it actually changes.
|
||||
///
|
||||
/// Calling ``ghostty_surface_set_color_scheme`` triggers
|
||||
/// ``syncAppearance(_:)`` via notification,
|
||||
/// so we avoid redundant calls.
|
||||
func updateColorSchemeForSurfaceTree() {
|
||||
/// Derive the target scheme from `window-theme` or system appearance.
|
||||
/// We set the scheme on surfaces so they pick the correct theme
|
||||
/// and let ``syncAppearance(_:)`` update the window accordingly.
|
||||
///
|
||||
/// Using App's effectiveAppearance here to prevent incorrect updates.
|
||||
let themeAppearance = NSApplication.shared.effectiveAppearance
|
||||
let scheme: ghostty_color_scheme_e
|
||||
if themeAppearance.isDark {
|
||||
scheme = GHOSTTY_COLOR_SCHEME_DARK
|
||||
} else {
|
||||
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
|
||||
}
|
||||
guard scheme != appliedColorScheme else {
|
||||
return
|
||||
}
|
||||
for surfaceView in surfaceTree {
|
||||
if let surface = surfaceView.surface {
|
||||
ghostty_surface_set_color_scheme(surface, scheme)
|
||||
}
|
||||
}
|
||||
appliedColorScheme = scheme
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ import GhosttyKit
|
||||
class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
|
||||
override var windowNibName: NSNib.Name? {
|
||||
let defaultValue = "Terminal"
|
||||
|
||||
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
|
||||
let config = appDelegate.ghostty.config
|
||||
|
||||
|
||||
// If we have no window decorations, there's no reason to do anything but
|
||||
// the default titlebar (because there will be no titlebar).
|
||||
if !config.windowDecorations {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
|
||||
let nib = switch config.macosTitlebarStyle {
|
||||
case "native": "Terminal"
|
||||
case "hidden": "TerminalHiddenTitlebar"
|
||||
@@ -34,32 +34,33 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
#endif
|
||||
default: defaultValue
|
||||
}
|
||||
|
||||
|
||||
return nib
|
||||
}
|
||||
|
||||
|
||||
/// This is set to true when we care about frame changes. This is a small optimization since
|
||||
/// this controller registers a listener for ALL frame change notifications and this lets us bail
|
||||
/// early if we don't care.
|
||||
private var tabListenForFrame: Bool = false
|
||||
|
||||
|
||||
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
|
||||
/// changes in the list.
|
||||
private var tabWindowsHash: Int = 0
|
||||
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
private var restorable: Bool = true
|
||||
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig
|
||||
|
||||
|
||||
|
||||
/// The notification cancellable for focused surface property changes.
|
||||
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
|
||||
/// This will be set to the initial frame of the window from the xib on load.
|
||||
private var initialFrame: NSRect? = nil
|
||||
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
|
||||
@@ -71,12 +72,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// as the script. We may want to revisit this behavior when we have scrollback
|
||||
// restoration.
|
||||
self.restorable = (base?.command ?? "") == ""
|
||||
|
||||
|
||||
// Setup our initial derived config based on the current app config
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
|
||||
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
||||
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
@@ -104,6 +105,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
selector: #selector(onCloseOtherTabs),
|
||||
name: .ghosttyCloseOtherTabs,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(onCloseTabsOnTheRight),
|
||||
name: .ghosttyCloseTabsOnTheRight,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(onResetWindowSize),
|
||||
@@ -128,46 +134,55 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported for this view")
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Remove all of our notificationcenter subscriptions
|
||||
let center = NotificationCenter.default
|
||||
center.removeObserver(self)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Base Controller Overrides
|
||||
|
||||
|
||||
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
super.surfaceTreeDidChange(from: from, to: to)
|
||||
|
||||
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
||||
// we want to invalidate our state.
|
||||
invalidateRestorableState()
|
||||
|
||||
|
||||
// Update our zoom state
|
||||
if let window = window as? TerminalWindow {
|
||||
window.surfaceIsZoomed = to.zoomed != nil
|
||||
}
|
||||
|
||||
|
||||
// If our surface tree is now nil then we close our window.
|
||||
if (to.isEmpty) {
|
||||
self.window?.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func fullscreenDidChange() {
|
||||
super.fullscreenDidChange()
|
||||
|
||||
// When our fullscreen state changes, we resync our appearance because some
|
||||
// properties change when fullscreen or not.
|
||||
guard let focusedSurface else { return }
|
||||
|
||||
syncAppearance(focusedSurface.derivedConfig)
|
||||
|
||||
override func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
undoAction: String? = nil
|
||||
) {
|
||||
// We have a special case if our tree is empty to close our tab immediately.
|
||||
// This makes it so that undo is handled properly.
|
||||
if newTree.isEmpty {
|
||||
closeTabImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
super.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: oldView,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
|
||||
// MARK: Terminal Creation
|
||||
@@ -190,7 +205,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
$0.window?.isMainWindow ?? false
|
||||
} ?? lastMain ?? all.last
|
||||
}
|
||||
|
||||
|
||||
// The last controller to be main. We use this when paired with "preferredParent"
|
||||
// to find the preferred window to attach new tabs, perform actions, etc. We
|
||||
// always prefer the main window but if there isn't any (because we're triggered
|
||||
@@ -280,6 +295,72 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
return c
|
||||
}
|
||||
|
||||
/// Create a new window with an existing split tree.
|
||||
/// The window will be sized to match the tree's current view bounds if available.
|
||||
/// - Parameters:
|
||||
/// - ghostty: The Ghostty app instance.
|
||||
/// - tree: The split tree to use for the new window.
|
||||
/// - position: Optional screen position (top-left corner) for the new window.
|
||||
/// If nil, the window will cascade from the last cascade point.
|
||||
static func newWindow(
|
||||
_ ghostty: Ghostty.App,
|
||||
tree: SplitTree<Ghostty.SurfaceView>,
|
||||
position: NSPoint? = nil,
|
||||
confirmUndo: Bool = true,
|
||||
) -> TerminalController {
|
||||
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
|
||||
|
||||
// Calculate the target frame based on the tree's view bounds
|
||||
let treeSize: CGSize? = tree.root?.viewBounds()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let window = c.window {
|
||||
// If we have a tree size, resize the window's content to match
|
||||
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
|
||||
window.setContentSize(treeSize)
|
||||
window.constrainToScreen()
|
||||
}
|
||||
|
||||
if !window.styleMask.contains(.fullScreen) {
|
||||
if let position {
|
||||
window.setFrameTopLeftPoint(position)
|
||||
window.constrainToScreen()
|
||||
} else {
|
||||
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.showWindow(self)
|
||||
}
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager = c.undoManager {
|
||||
undoManager.setActionName("New Window")
|
||||
undoManager.registerUndo(
|
||||
withTarget: c,
|
||||
expiresAfter: c.undoExpiration
|
||||
) { target in
|
||||
undoManager.disableUndoRegistration {
|
||||
if confirmUndo {
|
||||
target.closeWindow(nil)
|
||||
} else {
|
||||
target.closeWindowImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: ghostty,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { ghostty in
|
||||
_ = TerminalController.newWindow(ghostty, tree: tree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
static func newTab(
|
||||
_ ghostty: Ghostty.App,
|
||||
from parent: NSWindow? = nil,
|
||||
@@ -402,7 +483,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
//MARK: - Methods
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
@@ -425,15 +506,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// This is a surface-level config update. If we have the surface, we
|
||||
// update our appearance based on it.
|
||||
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.contains(surfaceView) else { return }
|
||||
|
||||
// We can't use surfaceView.derivedConfig because it may not be updated
|
||||
// yet since it also responds to notifications.
|
||||
syncAppearance(.init(config))
|
||||
/// Surface-level config will be updated in
|
||||
/// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then
|
||||
/// ``TerminalController/focusedSurfaceDidChange(to:)``
|
||||
}
|
||||
|
||||
/// Update the accessory view of each tab according to the keyboard
|
||||
@@ -489,6 +564,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
tabWindowsHash = v
|
||||
self.relabelTabs()
|
||||
}
|
||||
|
||||
override func syncAppearance() {
|
||||
// When our focus changes, we update our window appearance based on the
|
||||
// currently focused surface.
|
||||
guard let focusedSurface else { return }
|
||||
syncAppearance(focusedSurface.derivedConfig)
|
||||
}
|
||||
|
||||
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
// Let our window handle its own appearance
|
||||
@@ -518,13 +600,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
fromTopLeftOffsetX: CGFloat(x),
|
||||
offsetY: CGFloat(y),
|
||||
windowSize: frame.size)
|
||||
|
||||
|
||||
// Clamp the origin to ensure the window stays fully visible on screen
|
||||
var safeOrigin = origin
|
||||
let vf = screen.visibleFrame
|
||||
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
|
||||
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)
|
||||
|
||||
|
||||
// Return our new origin
|
||||
var result = frame
|
||||
result.origin = safeOrigin
|
||||
@@ -552,14 +634,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
closeWindow(nil)
|
||||
}
|
||||
|
||||
private func closeTabImmediately(registerRedo: Bool = true) {
|
||||
func closeTabImmediately(registerRedo: Bool = true) {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup,
|
||||
tabGroup.windows.count > 1 else {
|
||||
closeWindowImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Undo
|
||||
if let undoManager, let undoState {
|
||||
// Register undo action to restore the tab
|
||||
@@ -580,15 +662,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
window.close()
|
||||
}
|
||||
|
||||
|
||||
private func closeOtherTabsImmediately() {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup else { return }
|
||||
guard tabGroup.windows.count > 1 else { return }
|
||||
|
||||
|
||||
// Start an undo grouping
|
||||
if let undoManager {
|
||||
undoManager.beginUndoGrouping()
|
||||
@@ -596,7 +678,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
|
||||
// Iterate through all tabs except the current one.
|
||||
for window in tabGroup.windows where window != self.window {
|
||||
// We ignore any non-terminal tabs. They don't currently exist and we can't
|
||||
@@ -608,10 +690,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
controller.closeTabImmediately(registerRedo: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let undoManager {
|
||||
undoManager.setActionName("Close Other Tabs")
|
||||
|
||||
|
||||
// We need to register an undo that refocuses this window. Otherwise, the
|
||||
// undo operation above for each tab will steal focus.
|
||||
undoManager.registerUndo(
|
||||
@@ -621,7 +703,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
DispatchQueue.main.async {
|
||||
target.window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
|
||||
// Register redo action
|
||||
undoManager.registerUndo(
|
||||
withTarget: target,
|
||||
@@ -633,9 +715,49 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
}
|
||||
|
||||
private func closeTabsOnTheRightImmediately() {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup else { return }
|
||||
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
|
||||
|
||||
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
|
||||
guard !tabsToClose.isEmpty else { return }
|
||||
|
||||
undoManager?.beginUndoGrouping()
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
for (_, candidate) in tabsToClose {
|
||||
if let controller = candidate.windowController as? TerminalController {
|
||||
controller.closeTabImmediately(registerRedo: false)
|
||||
}
|
||||
}
|
||||
|
||||
if let undoManager {
|
||||
undoManager.setActionName("Close Tabs to the Right")
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
) { target in
|
||||
DispatchQueue.main.async {
|
||||
target.window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.closeTabsOnTheRightImmediately()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the current window (including any other tabs) immediately and without
|
||||
/// confirmation. This will setup proper undo state so the action can be undone.
|
||||
private func closeWindowImmediately() {
|
||||
func closeWindowImmediately() {
|
||||
guard let window = window else { return }
|
||||
|
||||
registerUndoForCloseWindow()
|
||||
@@ -707,7 +829,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
case (nil, nil): return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Find the index of the key window in our sorted states. This is a bit verbose
|
||||
// but we only need this for this style of undo so we don't want to add it to
|
||||
// UndoState.
|
||||
@@ -733,12 +855,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
let controllers = undoStates.map { undoState in
|
||||
TerminalController(ghostty, with: undoState)
|
||||
}
|
||||
|
||||
|
||||
// The first controller becomes the parent window for all tabs.
|
||||
// If we don't have a first controller (shouldn't be possible?)
|
||||
// then we can't restore tabs.
|
||||
guard let firstController = controllers.first else { return }
|
||||
|
||||
|
||||
// Add all subsequent controllers as tabs to the first window
|
||||
for controller in controllers.dropFirst() {
|
||||
controller.showWindow(nil)
|
||||
@@ -747,7 +869,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
firstWindow.addTabbedWindow(newWindow, ordered: .above)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Make the appropriate window key. If we had a key window, restore it.
|
||||
// Otherwise, make the last window key.
|
||||
if let keyWindowIndex, keyWindowIndex < controllers.count {
|
||||
@@ -813,6 +935,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
let focusedSurface: UUID?
|
||||
let tabIndex: Int?
|
||||
weak var tabGroup: NSWindowTabGroup?
|
||||
let tabColor: TerminalTabColor
|
||||
}
|
||||
|
||||
convenience init(_ ghostty: Ghostty.App,
|
||||
@@ -824,6 +947,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
showWindow(nil)
|
||||
if let window {
|
||||
window.setFrame(undoState.frame, display: true)
|
||||
if let terminalWindow = window as? TerminalWindow {
|
||||
terminalWindow.tabColor = undoState.tabColor
|
||||
}
|
||||
|
||||
// If we have a tab group and index, restore the tab to its original position
|
||||
if let tabGroup = undoState.tabGroup,
|
||||
@@ -839,13 +965,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// Make it the key window
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
|
||||
// Restore focus to the previously focused surface
|
||||
if let focusedUUID = undoState.focusedSurface,
|
||||
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusTarget, from: nil)
|
||||
}
|
||||
} else if let focusedSurface = surfaceTree.first {
|
||||
// No prior focused surface or we can't find it, let's focus
|
||||
// the first.
|
||||
self.focusedSurface = focusedSurface
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -859,7 +992,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
surfaceTree: surfaceTree,
|
||||
focusedSurface: focusedSurface?.id,
|
||||
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
|
||||
tabGroup: window.tabGroup)
|
||||
tabGroup: window.tabGroup,
|
||||
tabColor: (window as? TerminalWindow)?.tabColor ?? .none)
|
||||
}
|
||||
|
||||
//MARK: - NSWindowController
|
||||
@@ -895,35 +1029,39 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
|
||||
// Initialize our content view to the SwiftUI root
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self,
|
||||
))
|
||||
|
||||
)
|
||||
|
||||
// If we have a default size, we want to apply it.
|
||||
if let defaultSize {
|
||||
switch (defaultSize) {
|
||||
case .frame:
|
||||
// Frames can be applied immediately
|
||||
defaultSize.apply(to: window)
|
||||
|
||||
|
||||
case .contentIntrinsicSize:
|
||||
// Content intrinsic size requires a short delay so that AppKit
|
||||
// can layout our SwiftUI views.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in
|
||||
guard let window else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
defaultSize.apply(to: window)
|
||||
if let screen = window.screen ?? NSScreen.main {
|
||||
let frame = self.adjustForWindowPosition(frame: window.frame, on: screen)
|
||||
window.setFrameOrigin(frame.origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Store our initial frame so we can know our default later. This MUST
|
||||
// be after the defaultSize call above so that we don't re-apply our frame.
|
||||
// Note: we probably want to set this on the first frame change or something
|
||||
// so it respects cascade.
|
||||
initialFrame = window.frame
|
||||
|
||||
|
||||
// In various situations, macOS automatically tabs new windows. Ghostty handles
|
||||
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
|
||||
// it.
|
||||
@@ -1034,7 +1172,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
if let window {
|
||||
LastWindowPosition.shared.save(window)
|
||||
}
|
||||
|
||||
|
||||
// Remember our last main
|
||||
Self.lastMain = self
|
||||
}
|
||||
@@ -1081,27 +1219,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
@IBAction func closeOtherTabs(_ sender: Any?) {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup else { return }
|
||||
|
||||
|
||||
// If we only have one window then we have no other tabs to close
|
||||
guard tabGroup.windows.count > 1 else { return }
|
||||
|
||||
|
||||
// Check if we have to confirm close.
|
||||
guard tabGroup.windows.contains(where: { window in
|
||||
// Ignore ourself
|
||||
if window == self.window { return false }
|
||||
|
||||
|
||||
// Ignore non-terminals
|
||||
guard let controller = window.windowController as? TerminalController else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Check if any surfaces require confirmation
|
||||
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
|
||||
}) else {
|
||||
self.closeOtherTabsImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
confirmClose(
|
||||
messageText: "Close Other Tabs?",
|
||||
informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed."
|
||||
@@ -1110,6 +1248,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func closeTabsOnTheRight(_ sender: Any?) {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup else { return }
|
||||
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return }
|
||||
|
||||
let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex }
|
||||
guard !tabsToClose.isEmpty else { return }
|
||||
|
||||
let needsConfirm = tabsToClose.contains { (_, candidate) in
|
||||
guard let controller = candidate.windowController as? TerminalController else {
|
||||
return false
|
||||
}
|
||||
|
||||
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
|
||||
}
|
||||
|
||||
if !needsConfirm {
|
||||
self.closeTabsOnTheRightImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
confirmClose(
|
||||
messageText: "Close Tabs on the Right?",
|
||||
informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed."
|
||||
) {
|
||||
self.closeTabsOnTheRightImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func returnToDefaultSize(_ sender: Any?) {
|
||||
guard let window, let defaultSize else { return }
|
||||
defaultSize.apply(to: window)
|
||||
@@ -1151,7 +1318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
|
||||
//MARK: - TerminalViewDelegate
|
||||
|
||||
|
||||
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
|
||||
super.focusedSurfaceDidChange(to: to)
|
||||
|
||||
@@ -1215,7 +1382,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
// Get our target window
|
||||
let targetWindow = tabbedWindows[finalIndex]
|
||||
|
||||
|
||||
// Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs.
|
||||
// I believe this is due to messed up constraints for our hacky tab bar. I'd like to
|
||||
// find a better workaround. For now, this improves things dramatically.
|
||||
@@ -1228,7 +1395,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
DispatchQueue.main.async {
|
||||
selectedWindow.makeKey()
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1311,6 +1478,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
closeOtherTabs(self)
|
||||
}
|
||||
|
||||
@objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
closeTabsOnTheRight(self)
|
||||
}
|
||||
|
||||
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
@@ -1373,23 +1546,28 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
extension TerminalController {
|
||||
override func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
switch item.action {
|
||||
case #selector(closeTabsOnTheRight):
|
||||
guard let window, let tabGroup = window.tabGroup else { return false }
|
||||
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false }
|
||||
return tabGroup.windows.enumerated().contains { $0.offset > currentIndex }
|
||||
|
||||
case #selector(returnToDefaultSize):
|
||||
guard let window else { return false }
|
||||
|
||||
|
||||
// Native fullscreen windows can't revert to default size.
|
||||
if window.styleMask.contains(.fullScreen) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// If we're fullscreen at all then we can't change size
|
||||
if fullscreenStyle?.isFullscreen ?? false {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// If our window is already the default size or we don't have a
|
||||
// default size, then disable.
|
||||
return defaultSize?.isChanged(for: window) ?? false
|
||||
|
||||
|
||||
default:
|
||||
return super.validateMenuItem(item)
|
||||
}
|
||||
@@ -1405,10 +1583,10 @@ extension TerminalController {
|
||||
enum DefaultSize {
|
||||
/// A frame, set with `window.setFrame`
|
||||
case frame(NSRect)
|
||||
|
||||
|
||||
/// A content size, set with `window.setContentSize`
|
||||
case contentIntrinsicSize
|
||||
|
||||
|
||||
func isChanged(for window: NSWindow) -> Bool {
|
||||
switch self {
|
||||
case .frame(let rect):
|
||||
@@ -1417,11 +1595,11 @@ extension TerminalController {
|
||||
guard let view = window.contentView else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return view.frame.size != view.intrinsicContentSize
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func apply(to window: NSWindow) {
|
||||
switch self {
|
||||
case .frame(let rect):
|
||||
@@ -1430,13 +1608,13 @@ extension TerminalController {
|
||||
guard let size = window.contentView?.intrinsicContentSize else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
window.setContentSize(size)
|
||||
window.constrainToScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var defaultSize: DefaultSize? {
|
||||
if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main {
|
||||
// Maximize takes priority, we take up the full screen we're on.
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import Cocoa
|
||||
|
||||
/// The state stored for terminal window restoration.
|
||||
class TerminalRestorableState: Codable {
|
||||
static let selfKey = "state"
|
||||
static let versionKey = "version"
|
||||
static let version: Int = 5
|
||||
protocol TerminalRestorable: Codable {
|
||||
static var selfKey: String { get }
|
||||
static var versionKey: String { get }
|
||||
static var version: Int { get }
|
||||
init(copy other: Self)
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
let effectiveFullscreenMode: FullscreenMode?
|
||||
/// Returns a base configuration to use when restoring terminal surfaces.
|
||||
/// Override this to provide custom environment variables or other configuration.
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? { get }
|
||||
}
|
||||
|
||||
init(from controller: TerminalController) {
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
|
||||
}
|
||||
extension TerminalRestorable {
|
||||
static var selfKey: String { "state" }
|
||||
static var versionKey: String { "version" }
|
||||
|
||||
/// Default implementation returns nil (no custom base config).
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? { nil }
|
||||
|
||||
init?(coder aDecoder: NSCoder) {
|
||||
// If the version doesn't match then we can't decode. In the future we can perform
|
||||
@@ -28,9 +30,7 @@ class TerminalRestorableState: Codable {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.surfaceTree = v.value.surfaceTree
|
||||
self.focusedSurface = v.value.focusedSurface
|
||||
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
|
||||
self.init(copy: v.value)
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
@@ -39,6 +39,33 @@ class TerminalRestorableState: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state stored for terminal window restoration.
|
||||
class TerminalRestorableState: TerminalRestorable {
|
||||
class var version: Int { 7 }
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
let effectiveFullscreenMode: FullscreenMode?
|
||||
let tabColor: TerminalTabColor
|
||||
let titleOverride: String?
|
||||
|
||||
init(from controller: TerminalController) {
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
|
||||
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
|
||||
self.titleOverride = controller.titleOverride
|
||||
}
|
||||
|
||||
required init(copy other: TerminalRestorableState) {
|
||||
self.surfaceTree = other.surfaceTree
|
||||
self.focusedSurface = other.focusedSurface
|
||||
self.effectiveFullscreenMode = other.effectiveFullscreenMode
|
||||
self.tabColor = other.tabColor
|
||||
self.titleOverride = other.titleOverride
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalRestoreError: Error {
|
||||
case delegateInvalid
|
||||
case identifierUnknown
|
||||
@@ -94,6 +121,12 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||
return
|
||||
}
|
||||
|
||||
// Restore our tab color
|
||||
(window as? TerminalWindow)?.tabColor = state.tabColor
|
||||
|
||||
// Restore the tab title override
|
||||
c.titleOverride = state.titleOverride
|
||||
|
||||
// Setup our restored state on the controller
|
||||
// Find the focused surface in surfaceTree
|
||||
if let focusedStr = state.focusedSurface {
|
||||
@@ -158,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
185
macos/Sources/Features/Terminal/TerminalTabColor.swift
Normal file
185
macos/Sources/Features/Terminal/TerminalTabColor.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum TerminalTabColor: Int, CaseIterable, Codable {
|
||||
case none
|
||||
case blue
|
||||
case purple
|
||||
case pink
|
||||
case red
|
||||
case orange
|
||||
case yellow
|
||||
case green
|
||||
case teal
|
||||
case graphite
|
||||
|
||||
var localizedName: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return "None"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .yellow:
|
||||
return "Yellow"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
case .graphite:
|
||||
return "Graphite"
|
||||
}
|
||||
}
|
||||
|
||||
var displayColor: NSColor? {
|
||||
switch self {
|
||||
case .none:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .yellow:
|
||||
return .systemYellow
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .teal:
|
||||
if #available(macOS 13.0, *) {
|
||||
return .systemMint
|
||||
} else {
|
||||
return .systemTeal
|
||||
}
|
||||
case .graphite:
|
||||
return .systemGray
|
||||
}
|
||||
}
|
||||
|
||||
func swatchImage(selected: Bool) -> NSImage {
|
||||
let size = NSSize(width: 18, height: 18)
|
||||
return NSImage(size: size, flipped: false) { rect in
|
||||
let circleRect = rect.insetBy(dx: 1, dy: 1)
|
||||
let circlePath = NSBezierPath(ovalIn: circleRect)
|
||||
|
||||
if let fillColor = self.displayColor {
|
||||
fillColor.setFill()
|
||||
circlePath.fill()
|
||||
} else {
|
||||
NSColor.clear.setFill()
|
||||
circlePath.fill()
|
||||
NSColor.quaternaryLabelColor.setStroke()
|
||||
circlePath.lineWidth = 1
|
||||
circlePath.stroke()
|
||||
}
|
||||
|
||||
if self == .none {
|
||||
let slash = NSBezierPath()
|
||||
slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2))
|
||||
slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2))
|
||||
slash.lineWidth = 1.5
|
||||
NSColor.secondaryLabelColor.setStroke()
|
||||
slash.stroke()
|
||||
}
|
||||
|
||||
if selected {
|
||||
let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5))
|
||||
highlight.lineWidth = 2
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
highlight.stroke()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu View
|
||||
|
||||
/// A SwiftUI view displaying a color palette for tab color selection.
|
||||
/// Used as a custom view inside an NSMenuItem in the tab context menu.
|
||||
struct TabColorMenuView: View {
|
||||
@State private var currentSelection: TerminalTabColor
|
||||
let onSelect: (TerminalTabColor) -> Void
|
||||
|
||||
init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) {
|
||||
self._currentSelection = State(initialValue: selectedColor)
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Tab Color")
|
||||
.padding(.bottom, 2)
|
||||
|
||||
ForEach(Self.paletteRows, id: \.self) { row in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(row, id: \.self) { color in
|
||||
TabColorSwatch(
|
||||
color: color,
|
||||
isSelected: color == currentSelection
|
||||
) {
|
||||
currentSelection = color
|
||||
onSelect(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, Self.leadingPadding)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
static let paletteRows: [[TerminalTabColor]] = [
|
||||
[.none, .blue, .purple, .pink, .red],
|
||||
[.orange, .yellow, .green, .teal, .graphite],
|
||||
]
|
||||
|
||||
/// Leading padding to align with the menu's icon gutter.
|
||||
/// macOS 26 introduced icons in menus, requiring additional padding.
|
||||
private static var leadingPadding: CGFloat {
|
||||
if #available(macOS 26.0, *) {
|
||||
return 40
|
||||
} else {
|
||||
return 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single color swatch button in the tab color palette.
|
||||
private struct TabColorSwatch: View {
|
||||
let color: TerminalTabColor
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if color == .none {
|
||||
Image(systemName: isSelected ? "circle.slash" : "circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let displayColor = color.displayColor {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill")
|
||||
.foregroundStyle(Color(nsColor: displayColor))
|
||||
}
|
||||
}
|
||||
.font(.system(size: 16))
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(color.localizedName)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
import os
|
||||
|
||||
/// This delegate is notified of actions and property changes regarding the terminal view. This
|
||||
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
|
||||
@@ -16,9 +17,9 @@ protocol TerminalViewDelegate: AnyObject {
|
||||
|
||||
/// Perform an action. At the time of writing this is only triggered by the command palette.
|
||||
func performAction(_ action: String, on: Ghostty.SurfaceView)
|
||||
|
||||
/// A split is resizing to a given value.
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
|
||||
|
||||
/// A split tree operation
|
||||
func performSplitAction(_ action: TerminalSplitOperation)
|
||||
}
|
||||
|
||||
/// The view model is a required implementation for TerminalView callers. This contains
|
||||
@@ -81,7 +82,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
|
||||
TerminalSplitTreeView(
|
||||
tree: viewModel.surfaceTree,
|
||||
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
|
||||
action: { delegate?.performSplitAction($0) })
|
||||
.environmentObject(ghostty)
|
||||
.focused($focused)
|
||||
.onAppear { self.focused = true }
|
||||
|
||||
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Use this container to achieve a glass effect at the window level.
|
||||
/// Modifying `NSThemeFrame` can sometimes be unpredictable.
|
||||
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
|
||||
private let terminalView: NSView
|
||||
|
||||
/// Glass effect view for liquid glass background when transparency is enabled
|
||||
private var glassEffectView: NSView?
|
||||
private var glassTopConstraint: NSLayoutConstraint?
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
|
||||
self.derivedConfig = DerivedConfig(config: ghostty.config)
|
||||
self.terminalView = NSHostingView(rootView: TerminalView(
|
||||
ghostty: ghostty,
|
||||
viewModel: viewModel,
|
||||
delegate: delegate
|
||||
))
|
||||
super.init(frame: .zero)
|
||||
setup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// To make ``TerminalController/DefaultSize/contentIntrinsicSize``
|
||||
/// work in ``TerminalController/windowDidLoad()``,
|
||||
/// we override this to provide the correct size.
|
||||
override var intrinsicContentSize: NSSize {
|
||||
terminalView.intrinsicContentSize
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
addSubview(terminalView)
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.topAnchor.constraint(equalTo: topAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
terminalView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
updateGlassEffectIfNeeded()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
guard let config = notification.userInfo?[
|
||||
Notification.Name.GhosttyConfigChangeKey
|
||||
] as? Ghostty.Config else { return }
|
||||
let newValue = DerivedConfig(config: config)
|
||||
guard newValue != derivedConfig else { return }
|
||||
derivedConfig = newValue
|
||||
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Glass
|
||||
|
||||
private extension TerminalViewContainer {
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
|
||||
if let existed = glassEffectView as? NSGlassEffectView {
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
return existed
|
||||
}
|
||||
guard let themeFrameView = window?.contentView?.superview else {
|
||||
return nil
|
||||
}
|
||||
let effectView = NSGlassEffectView()
|
||||
addSubview(effectView, positioned: .below, relativeTo: terminalView)
|
||||
effectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
glassTopConstraint = effectView.topAnchor.constraint(
|
||||
equalTo: topAnchor,
|
||||
constant: -themeFrameView.safeAreaInsets.top
|
||||
)
|
||||
if let glassTopConstraint {
|
||||
NSLayoutConstraint.activate([
|
||||
glassTopConstraint,
|
||||
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
glassEffectView = effectView
|
||||
return effectView
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
|
||||
func updateGlassEffectIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
glassEffectView?.removeFromSuperview()
|
||||
glassEffectView = nil
|
||||
glassTopConstraint = nil
|
||||
return
|
||||
}
|
||||
guard let effectView = addGlassEffectViewIfNeeded() else {
|
||||
return
|
||||
}
|
||||
switch derivedConfig.backgroundBlur {
|
||||
case .macosGlassRegular:
|
||||
effectView.style = NSGlassEffectView.Style.regular
|
||||
case .macosGlassClear:
|
||||
effectView.style = NSGlassEffectView.Style.clear
|
||||
default:
|
||||
break
|
||||
}
|
||||
let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor)
|
||||
effectView.tintColor = backgroundColor
|
||||
.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||
if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat {
|
||||
effectView.cornerRadius = cornerRadius
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
func updateGlassEffectTopInsetIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
return
|
||||
}
|
||||
guard glassEffectView != nil else { return }
|
||||
guard let themeFrameView = window?.contentView?.superview else { return }
|
||||
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
struct DerivedConfig: Equatable {
|
||||
var backgroundOpacity: Double = 0
|
||||
var backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
var backgroundColor: Color = .clear
|
||||
|
||||
init(config: Ghostty.Config) {
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundColor = config.backgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import GhosttyKit
|
||||
class TerminalWindow: NSWindow {
|
||||
/// Posted when a terminal window awakes from nib.
|
||||
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
|
||||
|
||||
|
||||
/// Posted when a terminal window will close
|
||||
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
|
||||
|
||||
|
||||
/// This is the key in UserDefaults to use for the default `level` value. This is
|
||||
/// used by the manual float on top menu item feature.
|
||||
static let defaultLevelKey: String = "TerminalDefaultLevel"
|
||||
@@ -20,13 +20,23 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
/// Reset split zoom button in titlebar
|
||||
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
||||
|
||||
|
||||
/// Update notification UI in titlebar
|
||||
private let updateAccessory = NSTitlebarAccessoryViewController()
|
||||
|
||||
/// Visual indicator that mirrors the selected tab color.
|
||||
private lazy var tabColorIndicator: NSHostingView<TabColorIndicatorView> = {
|
||||
let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor))
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig = .init()
|
||||
|
||||
/// Sets up our tab context menu
|
||||
private var tabMenuObserver: NSObjectProtocol? = nil
|
||||
|
||||
/// Whether this window supports the update accessory. If this is false, then views within this
|
||||
/// window should determine how to show update notifications.
|
||||
var supportsUpdateAccessory: Bool {
|
||||
@@ -34,11 +44,24 @@ class TerminalWindow: NSWindow {
|
||||
true
|
||||
}
|
||||
|
||||
/// Glass effect view for liquid glass background when transparency is enabled
|
||||
private var glassEffectView: NSView?
|
||||
|
||||
/// Gets the terminal controller from the window controller.
|
||||
var terminalController: TerminalController? {
|
||||
windowController as? TerminalController
|
||||
}
|
||||
|
||||
|
||||
/// The color assigned to this window's tab. Setting this updates the tab color indicator
|
||||
/// and marks the window's restorable state as dirty.
|
||||
var tabColor: TerminalTabColor = .none {
|
||||
didSet {
|
||||
guard tabColor != oldValue else { return }
|
||||
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
|
||||
invalidateRestorableState()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSWindow Overrides
|
||||
|
||||
override var toolbar: NSToolbar? {
|
||||
@@ -53,7 +76,18 @@ class TerminalWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
// Notify that this terminal window has loaded
|
||||
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
|
||||
|
||||
|
||||
// This is fragile, but there doesn't seem to be an official API for customizing
|
||||
// native tab bar menus.
|
||||
tabMenuObserver = NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] n in
|
||||
guard let self, let menu = n.object as? NSMenu else { return }
|
||||
self.configureTabContextMenuIfNeeded(menu)
|
||||
}
|
||||
|
||||
// This is required so that window restoration properly creates our tabs
|
||||
// again. I'm not sure why this is required. If you don't do this, then
|
||||
// tabs restore as separate windows.
|
||||
@@ -61,14 +95,14 @@ class TerminalWindow: NSWindow {
|
||||
DispatchQueue.main.async {
|
||||
self.tabbingMode = .automatic
|
||||
}
|
||||
|
||||
|
||||
// All new windows are based on the app config at the time of creation.
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
|
||||
let config = appDelegate.ghostty.config
|
||||
|
||||
// Setup our initial config
|
||||
derivedConfig = .init(config)
|
||||
|
||||
|
||||
// If there is a hardcoded title in the configuration, we set that
|
||||
// immediately. Future `set_title` apprt actions will override this
|
||||
// if necessary but this ensures our window loads with the proper
|
||||
@@ -103,7 +137,7 @@ class TerminalWindow: NSWindow {
|
||||
}))
|
||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
|
||||
// Create update notification accessory
|
||||
if supportsUpdateAccessory {
|
||||
updateAccessory.layoutAttribute = .right
|
||||
@@ -119,9 +153,16 @@ class TerminalWindow: NSWindow {
|
||||
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
||||
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
|
||||
// where buttons were not clickable.
|
||||
let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
|
||||
tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor)
|
||||
|
||||
let stackView = NSStackView()
|
||||
stackView.orientation = .horizontal
|
||||
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
stackView.spacing = 3
|
||||
stackView.spacing = 4
|
||||
stackView.alignment = .centerY
|
||||
stackView.addArrangedSubview(tabColorIndicator)
|
||||
stackView.addArrangedSubview(keyEquivalentLabel)
|
||||
stackView.addArrangedSubview(resetZoomTabButton)
|
||||
tab.accessoryView = stackView
|
||||
|
||||
// Get our saved level
|
||||
@@ -132,7 +173,7 @@ class TerminalWindow: NSWindow {
|
||||
// still become key/main and receive events.
|
||||
override var canBecomeKey: Bool { return true }
|
||||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
|
||||
override func close() {
|
||||
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
|
||||
super.close()
|
||||
@@ -153,7 +194,7 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
// Its possible we miss the accessory titlebar call so we check again
|
||||
// whenever the window becomes main. Both of these are idempotent.
|
||||
if hasTabBar {
|
||||
if tabBarView != nil {
|
||||
tabBarDidAppear()
|
||||
} else {
|
||||
tabBarDidDisappear()
|
||||
@@ -202,31 +243,6 @@ class TerminalWindow: NSWindow {
|
||||
/// added.
|
||||
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||
|
||||
func findTitlebarView() -> NSView? {
|
||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||
//
|
||||
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||
guard let themeFrameView = contentView?.rootView else { return nil }
|
||||
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
|
||||
themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||
} else {
|
||||
NSView?.none
|
||||
}
|
||||
return titlebarView
|
||||
}
|
||||
|
||||
func findTabBar() -> NSView? {
|
||||
findTitlebarView()?.firstDescendant(withClassName: "NSTabBar")
|
||||
}
|
||||
|
||||
/// Returns true if there is a tab bar visible on this window.
|
||||
var hasTabBar: Bool {
|
||||
findTabBar() != nil
|
||||
}
|
||||
|
||||
var hasMoreThanOneTabs: Bool {
|
||||
/// accessing ``tabGroup?.windows`` here
|
||||
/// will cause other edge cases, be careful
|
||||
@@ -264,7 +280,7 @@ class TerminalWindow: NSWindow {
|
||||
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
||||
removeTitlebarAccessoryViewController(at: idx)
|
||||
}
|
||||
|
||||
|
||||
// We don't need to do this with the update accessory. I don't know why but
|
||||
// everything works fine.
|
||||
}
|
||||
@@ -419,6 +435,7 @@ class TerminalWindow: NSWindow {
|
||||
// have no effect if the window is not visible. Ultimately, we'll have this called
|
||||
// at some point when a surface becomes focused.
|
||||
guard isVisible else { return }
|
||||
defer { updateColorSchemeForSurfaceTree() }
|
||||
|
||||
// Basic properties
|
||||
appearance = surfaceConfig.windowAppearance
|
||||
@@ -427,8 +444,12 @@ class TerminalWindow: NSWindow {
|
||||
// Window transparency only takes effect if our window is not native fullscreen.
|
||||
// In native fullscreen we disable transparency/opacity because the background
|
||||
// becomes gray and widgets show through.
|
||||
//
|
||||
// Also check if the user has overridden transparency to be fully opaque.
|
||||
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
|
||||
if !styleMask.contains(.fullScreen) &&
|
||||
surfaceConfig.backgroundOpacity < 1
|
||||
!forceOpaque &&
|
||||
(surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle)
|
||||
{
|
||||
isOpaque = false
|
||||
|
||||
@@ -437,7 +458,8 @@ class TerminalWindow: NSWindow {
|
||||
// Terminal.app more easily.
|
||||
backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
// We don't need to set blur when using glass
|
||||
if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
ghostty_set_window_background_blur(
|
||||
appDelegate.ghostty.app,
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
@@ -481,9 +503,13 @@ class TerminalWindow: NSWindow {
|
||||
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
|
||||
}
|
||||
|
||||
func updateColorSchemeForSurfaceTree() {
|
||||
terminalController?.updateColorSchemeForSurfaceTree()
|
||||
}
|
||||
|
||||
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
|
||||
// If we don't have an X/Y then we try to use the previously saved window pos.
|
||||
guard let x, let y else {
|
||||
guard x != nil, y != nil else {
|
||||
if (!LastWindowPosition.shared.restore(self)) {
|
||||
center()
|
||||
}
|
||||
@@ -502,7 +528,7 @@ class TerminalWindow: NSWindow {
|
||||
center()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
|
||||
setFrameOrigin(frame.origin)
|
||||
}
|
||||
@@ -512,20 +538,32 @@ class TerminalWindow: NSWindow {
|
||||
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
standardWindowButton(.zoomButton)?.isHidden = true
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = tabMenuObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Config
|
||||
|
||||
struct DerivedConfig {
|
||||
let title: String?
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
let backgroundColor: NSColor
|
||||
let backgroundOpacity: Double
|
||||
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
||||
let macosTitlebarStyle: String
|
||||
let windowCornerRadius: CGFloat
|
||||
|
||||
init() {
|
||||
self.title = nil
|
||||
self.backgroundColor = NSColor.windowBackgroundColor
|
||||
self.backgroundOpacity = 1
|
||||
self.macosWindowButtons = .visible
|
||||
self.backgroundBlur = .disabled
|
||||
self.macosTitlebarStyle = "transparent"
|
||||
self.windowCornerRadius = 16
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
@@ -533,6 +571,18 @@ class TerminalWindow: NSWindow {
|
||||
self.backgroundColor = NSColor(config.backgroundColor)
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.macosWindowButtons = config.macosWindowButtons
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.macosTitlebarStyle = config.macosTitlebarStyle
|
||||
|
||||
// Set corner radius based on macos-titlebar-style
|
||||
// Native, transparent, and hidden styles use 16pt radius
|
||||
// Tabs style uses 20pt radius
|
||||
switch config.macosTitlebarStyle {
|
||||
case "tabs":
|
||||
self.windowCornerRadius = 20
|
||||
default:
|
||||
self.windowCornerRadius = 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,12 +629,12 @@ extension TerminalWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||
struct UpdateAccessoryView: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
|
||||
|
||||
var body: some View {
|
||||
// We use the same top/trailing padding so that it hugs the same.
|
||||
UpdatePill(model: model)
|
||||
@@ -594,3 +644,120 @@ extension TerminalWindow {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// A small circle indicator displayed in the tab accessory view that shows
|
||||
/// the user-assigned tab color. When no color is set, the view is hidden.
|
||||
private struct TabColorIndicatorView: View {
|
||||
/// The tab color to display.
|
||||
let tabColor: TerminalTabColor
|
||||
|
||||
var body: some View {
|
||||
if let color = tabColor.displayColor {
|
||||
Circle()
|
||||
.fill(Color(color))
|
||||
.frame(width: 6, height: 6)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.clear)
|
||||
.frame(width: 6, height: 6)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Context Menu
|
||||
|
||||
extension TerminalWindow {
|
||||
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
|
||||
private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem")
|
||||
private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator")
|
||||
|
||||
private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette")
|
||||
|
||||
func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
|
||||
guard isTabContextMenu(menu) else { return }
|
||||
|
||||
// Get the target from an existing menu item. The native tab context menu items
|
||||
// target the specific window/controller that was right-clicked, not the focused one.
|
||||
// We need to use that same target so validation and action use the correct tab.
|
||||
let targetController = menu.items
|
||||
.first { $0.action == NSSelectorFromString("performClose:") }
|
||||
.flatMap { $0.target as? NSWindow }
|
||||
.flatMap { $0.windowController as? TerminalController }
|
||||
|
||||
// Close tabs to the right
|
||||
let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "")
|
||||
item.identifier = Self.closeTabsOnRightMenuItemIdentifier
|
||||
item.target = targetController
|
||||
item.setImageIfDesired(systemSymbolName: "xmark")
|
||||
if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil,
|
||||
menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil {
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
// Other close items should have the xmark to match Safari on macOS 26
|
||||
for menuItem in menu.items {
|
||||
if menuItem.action == NSSelectorFromString("performClose:") ||
|
||||
menuItem.action == NSSelectorFromString("performCloseOtherTabs:") {
|
||||
menuItem.setImageIfDesired(systemSymbolName: "xmark")
|
||||
}
|
||||
}
|
||||
|
||||
appendTabModifierSection(to: menu, target: targetController)
|
||||
}
|
||||
|
||||
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
|
||||
guard NSApp.keyWindow === self else { return false }
|
||||
|
||||
// These selectors must all exist for it to be a tab context menu.
|
||||
let requiredSelectors: Set<String> = [
|
||||
"performClose:",
|
||||
"performCloseOtherTabs:",
|
||||
"moveTabToNewWindow:",
|
||||
"toggleTabOverview:"
|
||||
]
|
||||
|
||||
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
|
||||
return requiredSelectors.isSubset(of: selectorNames)
|
||||
}
|
||||
|
||||
private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) {
|
||||
menu.removeItems(withIdentifiers: [
|
||||
Self.tabColorSeparatorIdentifier,
|
||||
Self.changeTitleMenuItemIdentifier,
|
||||
Self.tabColorPaletteIdentifier
|
||||
])
|
||||
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.identifier = Self.tabColorSeparatorIdentifier
|
||||
menu.addItem(separator)
|
||||
|
||||
// Change Title...
|
||||
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
|
||||
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
|
||||
changeTitleItem.target = target
|
||||
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
menu.addItem(changeTitleItem)
|
||||
|
||||
let paletteItem = NSMenuItem()
|
||||
paletteItem.identifier = Self.tabColorPaletteIdentifier
|
||||
paletteItem.view = makeTabColorPaletteView(
|
||||
selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none
|
||||
) { [weak target] color in
|
||||
(target?.window as? TerminalWindow)?.tabColor = color
|
||||
}
|
||||
menu.addItem(paletteItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTabColorPaletteView(
|
||||
selectedColor: TerminalTabColor,
|
||||
selectionHandler: @escaping (TerminalTabColor) -> Void
|
||||
) -> NSView {
|
||||
let hostingView = NSHostingView(rootView: TabColorMenuView(
|
||||
selectedColor: selectedColor,
|
||||
onSelect: selectionHandler
|
||||
))
|
||||
hostingView.frame.size = hostingView.intrinsicContentSize
|
||||
return hostingView
|
||||
}
|
||||
|
||||
@@ -67,6 +67,38 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
|
||||
viewModel.isMainWindow = false
|
||||
}
|
||||
|
||||
/// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work
|
||||
/// naturally due to whatever mess we made.
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
guard viewModel.hasTabBar else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
let isRightClick =
|
||||
event.type == .rightMouseDown ||
|
||||
(event.type == .otherMouseDown && event.buttonNumber == 2) ||
|
||||
(event.type == .leftMouseDown && event.modifierFlags.contains(.control))
|
||||
guard isRightClick else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
guard let tabBarView else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil)
|
||||
guard tabBarView.bounds.contains(locationInTabBar) else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
tabBarView.rightMouseDown(with: event)
|
||||
}
|
||||
|
||||
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
|
||||
// this, detect the tab bar being added, and override its behavior.
|
||||
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
||||
@@ -144,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
guard tabBarObserver == nil else { return }
|
||||
|
||||
guard
|
||||
let titlebarView = findTitlebarView(),
|
||||
let tabBar = findTabBar()
|
||||
let titlebarView,
|
||||
let tabBarView = self.tabBarView
|
||||
else { return }
|
||||
|
||||
// View model updates must happen on their own ticks.
|
||||
@@ -154,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
}
|
||||
|
||||
// Find our clip view
|
||||
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
||||
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||
|
||||
// Make sure tabBar's height won't be stretched
|
||||
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
|
||||
tabBar.frame.size.height = newTabButton.frame.width
|
||||
tabBarView.frame.size.height = newTabButton.frame.width
|
||||
|
||||
// The container is the view that we'll constrain our tab bar within.
|
||||
let container = toolbarView
|
||||
@@ -196,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
// other events occur, the tab bar can resize and clear our constraints. When this
|
||||
// happens, we need to remove our custom constraints and re-apply them once the
|
||||
// tab bar has proper dimensions again to avoid constraint conflicts.
|
||||
tabBar.postsFrameChangedNotifications = true
|
||||
tabBarView.postsFrameChangedNotifications = true
|
||||
tabBarObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: tabBar,
|
||||
object: tabBarView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -290,7 +322,8 @@ extension TitlebarTabsTahoeTerminalWindow {
|
||||
} else {
|
||||
// 1x1.gif strikes again! For real: if we render a zero-sized
|
||||
// view here then the toolbar just disappears our view. I don't
|
||||
// know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
|
||||
// know. On macOS 26.1+ the view no longer disappears, but the
|
||||
// toolbar still logs an ambiguous content size warning.
|
||||
Color.clear.frame(width: 1, height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
/// Titlebar tabs can't support the update accessory because of the way we layout
|
||||
/// the native tabs back into the menu bar.
|
||||
override var supportsUpdateAccessory: Bool { false }
|
||||
|
||||
|
||||
/// This is used to determine if certain elements should be drawn light or dark and should
|
||||
/// be updated whenever the window background color or surrounding elements changes.
|
||||
fileprivate var isLightTheme: Bool = false
|
||||
@@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
// Hide the window drag handle.
|
||||
windowDragHandle?.isHidden = true
|
||||
|
||||
// Reenable the main toolbar title
|
||||
// Re-enable the main toolbar title
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleIsHidden = false
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
/// This is necessary because various macOS operations (tab switching, tab bar
|
||||
/// visibility changes) can reset the titlebar appearance.
|
||||
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
|
||||
|
||||
|
||||
/// KVO observation for tab group window changes.
|
||||
private var tabGroupWindowsObservation: NSKeyValueObservation?
|
||||
private var tabBarVisibleObservation: NSKeyValueObservation?
|
||||
|
||||
|
||||
deinit {
|
||||
tabGroupWindowsObservation?.invalidate()
|
||||
tabBarVisibleObservation?.invalidate()
|
||||
}
|
||||
|
||||
|
||||
// MARK: NSWindow
|
||||
|
||||
override func awakeFromNib() {
|
||||
@@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
|
||||
override func becomeMain() {
|
||||
super.becomeMain()
|
||||
|
||||
|
||||
guard let lastSurfaceConfig else { return }
|
||||
syncAppearance(lastSurfaceConfig)
|
||||
|
||||
@@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func update() {
|
||||
super.update()
|
||||
|
||||
@@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Save our config in case we need to reapply
|
||||
lastSurfaceConfig = surfaceConfig
|
||||
|
||||
// Everytime we change appearance, set KVO up again in case any of our
|
||||
// Every time we change appearance, set KVO up again in case any of our
|
||||
// references changed (e.g. tabGroup is new).
|
||||
setupKVO()
|
||||
|
||||
@@ -88,9 +88,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// color of the titlebar in native fullscreen view.
|
||||
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
|
||||
titlebarView.wantsLayer = true
|
||||
titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor
|
||||
|
||||
// For glass background styles, use a transparent titlebar to let the glass effect show through
|
||||
// Only apply this for transparent and tabs titlebar styles
|
||||
let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle
|
||||
let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" ||
|
||||
derivedConfig.macosTitlebarStyle == "tabs"
|
||||
|
||||
titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar)
|
||||
? NSColor.clear.cgColor
|
||||
: preferredBackgroundColor?.cgColor
|
||||
}
|
||||
|
||||
|
||||
// In all cases, we have to hide the background view since this has multiple subviews
|
||||
// that force a background color.
|
||||
titlebarBackgroundView?.isHidden = true
|
||||
@@ -99,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
@available(macOS 13.0, *)
|
||||
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
guard let titlebarContainer else { return }
|
||||
|
||||
|
||||
// Setup the titlebar background color to match ours
|
||||
titlebarContainer.wantsLayer = true
|
||||
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
|
||||
|
||||
|
||||
// See the docs for the function that sets this to true on why
|
||||
effectViewIsHidden = false
|
||||
|
||||
|
||||
// Necessary to not draw the border around the title
|
||||
titlebarAppearsTransparent = true
|
||||
}
|
||||
@@ -132,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Remove existing observation if any
|
||||
tabGroupWindowsObservation?.invalidate()
|
||||
tabGroupWindowsObservation = nil
|
||||
|
||||
|
||||
// Check if tabGroup is available
|
||||
guard let tabGroup else { return }
|
||||
|
||||
@@ -161,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Remove existing observation if any
|
||||
tabBarVisibleObservation?.invalidate()
|
||||
tabBarVisibleObservation = nil
|
||||
|
||||
|
||||
// Set up KVO observation for isTabBarVisible
|
||||
tabBarVisibleObservation = tabGroup?.observe(
|
||||
\.isTabBarVisible,
|
||||
@@ -172,18 +181,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
self.syncAppearance(lastSurfaceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: macOS 13 to 15
|
||||
|
||||
|
||||
// We only need to set this once, but need to do it after the window has been created in order
|
||||
// to determine if the theme is using a very dark background, in which case we don't want to
|
||||
// remove the effect view if the default tab bar is being used since the effect created in
|
||||
// `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
|
||||
private var effectViewIsHidden = false
|
||||
|
||||
|
||||
private func hideEffectView() {
|
||||
guard !effectViewIsHidden else { return }
|
||||
|
||||
|
||||
// By hiding the visual effect view, we allow the window's (or titlebar's in this case)
|
||||
// background color to show through. If we were to set `titlebarAppearsTransparent` to true
|
||||
// the selected tab would look fine, but the unselected ones and new tab button backgrounds
|
||||
|
||||
@@ -127,6 +127,41 @@ extension Ghostty.Action {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PromptTitle {
|
||||
case surface
|
||||
case tab
|
||||
|
||||
init(_ c: ghostty_action_prompt_title_e) {
|
||||
switch c {
|
||||
case GHOSTTY_PROMPT_TITLE_TAB:
|
||||
self = .tab
|
||||
default:
|
||||
self = .surface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum KeyTable {
|
||||
case activate(name: String)
|
||||
case deactivate
|
||||
case deactivateAll
|
||||
|
||||
init?(c: ghostty_action_key_table_s) {
|
||||
switch c.tag {
|
||||
case GHOSTTY_KEY_TABLE_ACTIVATE:
|
||||
let data = Data(bytes: c.value.activate.name, count: c.value.activate.len)
|
||||
let name = String(data: data, encoding: .utf8) ?? ""
|
||||
self = .activate(name: name)
|
||||
case GHOSTTY_KEY_TABLE_DEACTIVATE:
|
||||
self = .deactivate
|
||||
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
|
||||
self = .deactivateAll
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Putting the initializer in an extension preserves the automatic one.
|
||||
|
||||
@@ -29,6 +29,8 @@ extension Ghostty {
|
||||
/// configuration (i.e. font size) from the previously focused window. This would override this.
|
||||
@Published private(set) var config: Config
|
||||
|
||||
/// Preferred config file than the default ones
|
||||
private var configPath: String?
|
||||
/// The ghostty app instance. We only have one of these for the entire app, although I guess
|
||||
/// in theory you can have multiple... I don't know why you would...
|
||||
@Published var app: ghostty_app_t? = nil {
|
||||
@@ -44,9 +46,10 @@ extension Ghostty {
|
||||
return ghostty_app_needs_confirm_quit(app)
|
||||
}
|
||||
|
||||
init() {
|
||||
init(configPath: String? = nil) {
|
||||
self.configPath = configPath
|
||||
// Initialize the global configuration.
|
||||
self.config = Config()
|
||||
self.config = Config(at: configPath)
|
||||
if self.config.config == nil {
|
||||
readiness = .error
|
||||
return
|
||||
@@ -143,7 +146,7 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
// Hard or full updates have to reload the full configuration
|
||||
let newConfig = Config()
|
||||
let newConfig = Config(at: configPath)
|
||||
guard newConfig.loaded else {
|
||||
Ghostty.logger.warning("failed to reload configuration")
|
||||
return
|
||||
@@ -163,7 +166,7 @@ extension Ghostty {
|
||||
// Hard or full updates have to reload the full configuration.
|
||||
// NOTE: We never set this on self.config because this is a surface-only
|
||||
// config. We free it after the call.
|
||||
let newConfig = Config()
|
||||
let newConfig = Config(at: configPath)
|
||||
guard newConfig.loaded else {
|
||||
Ghostty.logger.warning("failed to reload configuration")
|
||||
return
|
||||
@@ -501,14 +504,17 @@ extension Ghostty {
|
||||
case GHOSTTY_ACTION_GOTO_SPLIT:
|
||||
return gotoSplit(app, target: target, direction: action.action.goto_split)
|
||||
|
||||
case GHOSTTY_ACTION_GOTO_WINDOW:
|
||||
return gotoWindow(app, target: target, direction: action.action.goto_window)
|
||||
|
||||
case GHOSTTY_ACTION_RESIZE_SPLIT:
|
||||
resizeSplit(app, target: target, resize: action.action.resize_split)
|
||||
return resizeSplit(app, target: target, resize: action.action.resize_split)
|
||||
|
||||
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
|
||||
equalizeSplits(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
|
||||
toggleSplitZoom(app, target: target)
|
||||
return toggleSplitZoom(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_INSPECTOR:
|
||||
controlInspector(app, target: target, mode: action.action.inspector)
|
||||
@@ -523,7 +529,7 @@ extension Ghostty {
|
||||
setTitle(app, target: target, v: action.action.set_title)
|
||||
|
||||
case GHOSTTY_ACTION_PROMPT_TITLE:
|
||||
return promptTitle(app, target: target)
|
||||
return promptTitle(app, target: target, v: action.action.prompt_title)
|
||||
|
||||
case GHOSTTY_ACTION_PWD:
|
||||
pwdChanged(app, target: target, v: action.action.pwd)
|
||||
@@ -570,9 +576,15 @@ extension Ghostty {
|
||||
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
|
||||
toggleVisibility(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
|
||||
toggleBackgroundOpacity(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_KEY_SEQUENCE:
|
||||
keySequence(app, target: target, v: action.action.key_sequence)
|
||||
|
||||
|
||||
case GHOSTTY_ACTION_KEY_TABLE:
|
||||
keyTable(app, target: target, v: action.action.key_table)
|
||||
|
||||
case GHOSTTY_ACTION_PROGRESS_REPORT:
|
||||
progressReport(app, target: target, v: action.action.progress_report)
|
||||
|
||||
@@ -588,6 +600,9 @@ extension Ghostty {
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
ringBell(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_READONLY:
|
||||
setReadonly(app, target: target, v: action.action.readonly)
|
||||
|
||||
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
|
||||
checkForUpdates(app)
|
||||
|
||||
@@ -618,12 +633,13 @@ extension Ghostty {
|
||||
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
||||
searchSelected(app, target: target, v: action.action.search_selected)
|
||||
|
||||
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
||||
return presentTerminal(app, target: target)
|
||||
|
||||
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_SIZE_LIMIT:
|
||||
fallthrough
|
||||
case GHOSTTY_ACTION_QUIT_TIMER:
|
||||
@@ -760,7 +776,7 @@ extension Ghostty {
|
||||
name: Notification.ghosttyNewWindow,
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -797,7 +813,7 @@ extension Ghostty {
|
||||
name: Notification.ghosttyNewTab,
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -826,7 +842,7 @@ extension Ghostty {
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
"direction": direction,
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -836,6 +852,30 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
private static func presentTerminal(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s
|
||||
) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyPresentTerminal,
|
||||
object: surfaceView
|
||||
)
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
@@ -861,6 +901,13 @@ extension Ghostty {
|
||||
)
|
||||
return
|
||||
|
||||
case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT:
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyCloseTabsOnTheRight,
|
||||
object: surfaceView
|
||||
)
|
||||
return
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
@@ -1003,6 +1050,31 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
private static func setReadonly(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
v: ghostty_action_readonly_e) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("set readonly does nothing with an app target")
|
||||
return
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidChangeReadonly,
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON,
|
||||
]
|
||||
)
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private static func moveTab(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
@@ -1114,19 +1186,82 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
private static func gotoWindow(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
direction: ghostty_action_goto_window_e
|
||||
) -> Bool {
|
||||
// Collect candidate windows: visible terminal windows that are either
|
||||
// standalone or the currently selected tab in their tab group. This
|
||||
// treats each native tab group as a single "window" for navigation
|
||||
// purposes, since goto_tab handles per-tab navigation.
|
||||
let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in
|
||||
guard window.windowController is BaseTerminalController else { return false }
|
||||
guard window.isVisible, !window.isMiniaturized else { return false }
|
||||
// For native tabs, only include the selected tab in each group
|
||||
if let group = window.tabGroup, group.selectedWindow !== window {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Need at least two windows to navigate between
|
||||
guard candidates.count > 1 else { return false }
|
||||
|
||||
// Find starting index from the current key/main window
|
||||
let startIndex = candidates.firstIndex(where: { $0.isKeyWindow })
|
||||
?? candidates.firstIndex(where: { $0.isMainWindow })
|
||||
?? 0
|
||||
|
||||
let step: Int
|
||||
switch direction {
|
||||
case GHOSTTY_GOTO_WINDOW_NEXT:
|
||||
step = 1
|
||||
case GHOSTTY_GOTO_WINDOW_PREVIOUS:
|
||||
step = -1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// Iterate with wrap-around until we find a valid window or return to start
|
||||
let count = candidates.count
|
||||
var index = (startIndex + step + count) % count
|
||||
|
||||
while index != startIndex {
|
||||
let candidate = candidates[index]
|
||||
if candidate.isVisible, !candidate.isMiniaturized {
|
||||
candidate.makeKeyAndOrderFront(nil)
|
||||
// Also focus the terminal surface within the window
|
||||
if let controller = candidate.windowController as? BaseTerminalController,
|
||||
let surface = controller.focusedSurface {
|
||||
Ghostty.moveFocus(to: surface)
|
||||
}
|
||||
return true
|
||||
}
|
||||
index = (index + step + count) % count
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func resizeSplit(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
resize: ghostty_action_resize_split_s) {
|
||||
resize: ghostty_action_resize_split_s) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("resize split does nothing with an app target")
|
||||
return
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return }
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
guard controller.surfaceTree.isSplit else { return false }
|
||||
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didResizeSplit,
|
||||
object: surfaceView,
|
||||
@@ -1135,9 +1270,11 @@ extension Ghostty {
|
||||
Notification.ResizeSplitAmountKey: resize.amount,
|
||||
]
|
||||
)
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,23 +1302,30 @@ extension Ghostty {
|
||||
|
||||
private static func toggleSplitZoom(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s) {
|
||||
target: ghostty_target_s) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
|
||||
return
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
||||
|
||||
// If the window has no splits, the action is not performable
|
||||
guard controller.surfaceTree.isSplit else { return false }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didToggleSplitZoom,
|
||||
object: surfaceView
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1423,27 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
private static func toggleBackgroundOpacity(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s
|
||||
) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("toggle background opacity does nothing with an app target")
|
||||
return
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface,
|
||||
let surfaceView = self.surfaceView(from: surface),
|
||||
let controller = surfaceView.window?.windowController as? BaseTerminalController else { return }
|
||||
|
||||
controller.toggleBackgroundOpacity()
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private static func toggleSecureInput(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
@@ -1343,22 +1508,50 @@ extension Ghostty {
|
||||
|
||||
private static func promptTitle(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s) -> Bool {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("set title prompt does nothing with an app target")
|
||||
return false
|
||||
target: ghostty_target_s,
|
||||
v: ghostty_action_prompt_title_e) -> Bool {
|
||||
let promptTitle = Action.PromptTitle(v)
|
||||
switch promptTitle {
|
||||
case .surface:
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("set title prompt does nothing with an app target")
|
||||
return false
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
surfaceView.promptTitle()
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
surfaceView.promptTitle()
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
case .tab:
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
guard let window = NSApp.mainWindow ?? NSApp.keyWindow,
|
||||
let controller = window.windowController as? BaseTerminalController
|
||||
else { return false }
|
||||
controller.promptTabTitle()
|
||||
return true
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return false }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||
guard let window = surfaceView.window,
|
||||
let controller = window.windowController as? BaseTerminalController
|
||||
else { return false }
|
||||
controller.promptTabTitle()
|
||||
return true
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private static func pwdChanged(
|
||||
@@ -1598,7 +1791,32 @@ extension Ghostty {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func keyTable(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
v: ghostty_action_key_table_s) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("key table does nothing with an app target")
|
||||
return
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let action = Ghostty.Action.KeyTable(c: v) else { return }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didChangeKeyTable,
|
||||
object: surfaceView,
|
||||
userInfo: [Notification.KeyTableKey: action]
|
||||
)
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private static func progressReport(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
@@ -1668,11 +1886,15 @@ extension Ghostty {
|
||||
|
||||
let startSearch = Ghostty.Action.StartSearch(c: v)
|
||||
DispatchQueue.main.async {
|
||||
if surfaceView.searchState != nil {
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
|
||||
if let searchState = surfaceView.searchState {
|
||||
if let needle = startSearch.needle, !needle.isEmpty {
|
||||
searchState.needle = needle
|
||||
}
|
||||
} else {
|
||||
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -3,28 +3,18 @@ import GhosttyKit
|
||||
extension Ghostty {
|
||||
/// `ghostty_command_s`
|
||||
struct Command: Sendable {
|
||||
private let cValue: ghostty_command_s
|
||||
|
||||
/// The title of the command.
|
||||
var title: String {
|
||||
String(cString: cValue.title)
|
||||
}
|
||||
let title: String
|
||||
|
||||
/// Human-friendly description of what this command will do.
|
||||
var description: String {
|
||||
String(cString: cValue.description)
|
||||
}
|
||||
let description: String
|
||||
|
||||
/// The full action that must be performed to invoke this command.
|
||||
var action: String {
|
||||
String(cString: cValue.action)
|
||||
}
|
||||
let action: String
|
||||
|
||||
/// Only the key portion of the action so you can compare action types, e.g. `goto_split`
|
||||
/// instead of `goto_split:left`.
|
||||
var actionKey: String {
|
||||
String(cString: cValue.action_key)
|
||||
}
|
||||
let actionKey: String
|
||||
|
||||
/// True if this can be performed on this target.
|
||||
var isSupported: Bool {
|
||||
@@ -40,7 +30,10 @@ extension Ghostty {
|
||||
]
|
||||
|
||||
init(cValue: ghostty_command_s) {
|
||||
self.cValue = cValue
|
||||
self.title = String(cString: cValue.title)
|
||||
self.description = String(cString: cValue.description)
|
||||
self.action = String(cString: cValue.action)
|
||||
self.actionKey = String(cString: cValue.action_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ extension Ghostty {
|
||||
return diags
|
||||
}
|
||||
|
||||
init() {
|
||||
if let cfg = Self.loadConfig() {
|
||||
self.config = cfg
|
||||
}
|
||||
init(config: ghostty_config_t?) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
init(clone config: ghostty_config_t) {
|
||||
self.config = ghostty_config_clone(config)
|
||||
convenience init(at path: String? = nil, finalize: Bool = true) {
|
||||
self.init(config: Self.loadConfig(at: path, finalize: finalize))
|
||||
}
|
||||
|
||||
convenience init(clone config: ghostty_config_t) {
|
||||
self.init(config: ghostty_config_clone(config))
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -48,7 +50,10 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
/// Initializes a new configuration and loads all the values.
|
||||
static private func loadConfig() -> ghostty_config_t? {
|
||||
/// - Parameters:
|
||||
/// - path: An optional preferred config file path. Pass `nil` to load the default configuration files.
|
||||
/// - finalize: Whether to finalize the configuration to populate default values.
|
||||
static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? {
|
||||
// Initialize the global configuration.
|
||||
guard let cfg = ghostty_config_new() else {
|
||||
logger.critical("ghostty_config_new failed")
|
||||
@@ -59,7 +64,11 @@ extension Ghostty {
|
||||
// We only do this on macOS because other Apple platforms do not have the
|
||||
// same filesystem concept.
|
||||
#if os(macOS)
|
||||
ghostty_config_load_default_files(cfg);
|
||||
if let path {
|
||||
ghostty_config_load_file(cfg, path)
|
||||
} else {
|
||||
ghostty_config_load_default_files(cfg)
|
||||
}
|
||||
|
||||
// We only load CLI args when not running in Xcode because in Xcode we
|
||||
// pass some special parameters to control the debugger.
|
||||
@@ -74,9 +83,10 @@ extension Ghostty {
|
||||
// have to do this synchronously. When we support config updating we can do
|
||||
// this async and update later.
|
||||
|
||||
// Finalize will make our defaults available.
|
||||
ghostty_config_finalize(cfg)
|
||||
|
||||
if finalize {
|
||||
// Finalize will make our defaults available.
|
||||
ghostty_config_finalize(cfg)
|
||||
}
|
||||
// Log any configuration errors. These will be automatically shown in a
|
||||
// pop-up window too.
|
||||
let diagsCount = ghostty_config_diagnostics_count(cfg)
|
||||
@@ -124,6 +134,14 @@ extension Ghostty {
|
||||
return .init(rawValue: v)
|
||||
}
|
||||
|
||||
var splitPreserveZoom: SplitPreserveZoom {
|
||||
guard let config = self.config else { return .init() }
|
||||
var v: CUnsignedInt = 0
|
||||
let key = "split-preserve-zoom"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() }
|
||||
return .init(rawValue: v)
|
||||
}
|
||||
|
||||
var initialWindow: Bool {
|
||||
guard let config = self.config else { return true }
|
||||
var v = true;
|
||||
@@ -402,12 +420,12 @@ extension Ghostty {
|
||||
return v;
|
||||
}
|
||||
|
||||
var backgroundBlurRadius: Int {
|
||||
guard let config = self.config else { return 1 }
|
||||
var v: Int = 0
|
||||
var backgroundBlur: BackgroundBlur {
|
||||
guard let config = self.config else { return .disabled }
|
||||
var v: Int16 = 0
|
||||
let key = "background-blur"
|
||||
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return v;
|
||||
return BackgroundBlur(fromCValue: v)
|
||||
}
|
||||
|
||||
var unfocusedSplitOpacity: Double {
|
||||
@@ -614,6 +632,16 @@ extension Ghostty {
|
||||
let str = String(cString: ptr)
|
||||
return Scrollbar(rawValue: str) ?? defaultValue
|
||||
}
|
||||
|
||||
var commandPaletteEntries: [Ghostty.Command] {
|
||||
guard let config = self.config else { return [] }
|
||||
var v: ghostty_config_command_list_s = .init()
|
||||
let key = "command-palette-entry"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] }
|
||||
guard v.len > 0 else { return [] }
|
||||
let buffer = UnsafeBufferPointer(start: v.commands, count: v.len)
|
||||
return buffer.map { Ghostty.Command(cValue: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,6 +654,68 @@ extension Ghostty.Config {
|
||||
case download
|
||||
}
|
||||
|
||||
/// Background blur configuration that maps from the C API values.
|
||||
/// Positive values represent blur radius, special negative values
|
||||
/// represent macOS-specific glass effects.
|
||||
enum BackgroundBlur: Equatable {
|
||||
case disabled
|
||||
case radius(Int)
|
||||
case macosGlassRegular
|
||||
case macosGlassClear
|
||||
|
||||
init(fromCValue value: Int16) {
|
||||
switch value {
|
||||
case 0:
|
||||
self = .disabled
|
||||
case -1:
|
||||
if #available(macOS 26.0, *) {
|
||||
self = .macosGlassRegular
|
||||
} else {
|
||||
self = .disabled
|
||||
}
|
||||
case -2:
|
||||
if #available(macOS 26.0, *) {
|
||||
self = .macosGlassClear
|
||||
} else {
|
||||
self = .disabled
|
||||
}
|
||||
default:
|
||||
self = .radius(Int(value))
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
switch self {
|
||||
case .disabled:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this is a macOS glass style (regular or clear).
|
||||
var isGlassStyle: Bool {
|
||||
switch self {
|
||||
case .macosGlassRegular, .macosGlassClear:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the blur radius if applicable, nil for glass effects.
|
||||
var radius: Int? {
|
||||
switch self {
|
||||
case .disabled:
|
||||
return nil
|
||||
case .radius(let r):
|
||||
return r
|
||||
case .macosGlassRegular, .macosGlassClear:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BellFeatures: OptionSet {
|
||||
let rawValue: CUnsignedInt
|
||||
|
||||
@@ -635,6 +725,12 @@ extension Ghostty.Config {
|
||||
static let title = BellFeatures(rawValue: 1 << 3)
|
||||
static let border = BellFeatures(rawValue: 1 << 4)
|
||||
}
|
||||
|
||||
struct SplitPreserveZoom: OptionSet {
|
||||
let rawValue: CUnsignedInt
|
||||
|
||||
static let navigation = SplitPreserveZoom(rawValue: 1 << 0)
|
||||
}
|
||||
|
||||
enum MacDockDropBehavior: String {
|
||||
case new_tab = "new-tab"
|
||||
|
||||
@@ -32,6 +32,10 @@ extension Ghostty {
|
||||
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
|
||||
key = KeyEquivalent(Character(scalar))
|
||||
|
||||
case GHOSTTY_TRIGGER_CATCH_ALL:
|
||||
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -64,7 +68,7 @@ extension Ghostty {
|
||||
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
|
||||
|
||||
// Handle sided input. We can't tell that both are pressed in the
|
||||
// Ghostty structure but thats okay -- we don't use that information.
|
||||
// Ghostty structure but that's okay -- we don't use that information.
|
||||
let rawFlags = flags.rawValue
|
||||
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
|
||||
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
|
||||
@@ -96,6 +100,32 @@ extension Ghostty {
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.BindingFlags
|
||||
|
||||
extension Ghostty.Input {
|
||||
/// `ghostty_binding_flags_e`
|
||||
struct BindingFlags: OptionSet, Sendable {
|
||||
let rawValue: UInt32
|
||||
|
||||
static let consumed = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue)
|
||||
static let all = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_ALL.rawValue)
|
||||
static let global = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_GLOBAL.rawValue)
|
||||
static let performable = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue)
|
||||
|
||||
init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
init(cFlags: ghostty_binding_flags_e) {
|
||||
self.rawValue = cFlags.rawValue
|
||||
}
|
||||
|
||||
var cFlags: ghostty_binding_flags_e {
|
||||
ghostty_binding_flags_e(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.KeyEvent
|
||||
|
||||
extension Ghostty.Input {
|
||||
@@ -135,7 +165,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_ACTION_REPEAT: self.action = .repeat
|
||||
default: self.action = .press
|
||||
}
|
||||
|
||||
|
||||
// Convert key from keycode
|
||||
guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
|
||||
self.key = key
|
||||
@@ -146,18 +176,18 @@ extension Ghostty.Input {
|
||||
} else {
|
||||
self.text = nil
|
||||
}
|
||||
|
||||
|
||||
// Set composing state
|
||||
self.composing = cValue.composing
|
||||
|
||||
|
||||
// Convert modifiers
|
||||
self.mods = Mods(cMods: cValue.mods)
|
||||
self.consumedMods = Mods(cMods: cValue.consumed_mods)
|
||||
|
||||
|
||||
// Set unshifted codepoint
|
||||
self.unshiftedCodepoint = cValue.unshifted_codepoint
|
||||
}
|
||||
|
||||
|
||||
/// Executes a closure with a temporary C representation of this KeyEvent.
|
||||
///
|
||||
/// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
|
||||
@@ -176,7 +206,7 @@ extension Ghostty.Input {
|
||||
keyEvent.mods = mods.cMods
|
||||
keyEvent.consumed_mods = consumedMods.cMods
|
||||
keyEvent.unshifted_codepoint = unshiftedCodepoint
|
||||
|
||||
|
||||
// Handle text with proper memory management
|
||||
if let text = text {
|
||||
return text.withCString { textPtr in
|
||||
@@ -199,7 +229,7 @@ extension Ghostty.Input {
|
||||
case release
|
||||
case press
|
||||
case `repeat`
|
||||
|
||||
|
||||
var cAction: ghostty_input_action_e {
|
||||
switch self {
|
||||
case .release: GHOSTTY_ACTION_RELEASE
|
||||
@@ -228,7 +258,7 @@ extension Ghostty.Input {
|
||||
let action: MouseState
|
||||
let button: MouseButton
|
||||
let mods: Mods
|
||||
|
||||
|
||||
init(
|
||||
action: MouseState,
|
||||
button: MouseButton,
|
||||
@@ -238,7 +268,7 @@ extension Ghostty.Input {
|
||||
self.button = button
|
||||
self.mods = mods
|
||||
}
|
||||
|
||||
|
||||
/// Creates a MouseEvent from C enum values.
|
||||
///
|
||||
/// This initializer converts C-style mouse input enums to Swift types.
|
||||
@@ -255,7 +285,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_MOUSE_PRESS: self.action = .press
|
||||
default: return nil
|
||||
}
|
||||
|
||||
|
||||
// Convert button
|
||||
switch button {
|
||||
case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown
|
||||
@@ -264,7 +294,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_MOUSE_MIDDLE: self.button = .middle
|
||||
default: return nil
|
||||
}
|
||||
|
||||
|
||||
// Convert modifiers
|
||||
self.mods = Mods(cMods: mods)
|
||||
}
|
||||
@@ -275,7 +305,7 @@ extension Ghostty.Input {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let mods: Mods
|
||||
|
||||
|
||||
init(
|
||||
x: Double,
|
||||
y: Double,
|
||||
@@ -312,7 +342,7 @@ extension Ghostty.Input {
|
||||
enum MouseState: String, CaseIterable {
|
||||
case release
|
||||
case press
|
||||
|
||||
|
||||
var cMouseState: ghostty_input_mouse_state_e {
|
||||
switch self {
|
||||
case .release: GHOSTTY_MOUSE_RELEASE
|
||||
@@ -340,13 +370,48 @@ extension Ghostty.Input {
|
||||
case left
|
||||
case right
|
||||
case middle
|
||||
|
||||
case four
|
||||
case five
|
||||
case six
|
||||
case seven
|
||||
case eight
|
||||
case nine
|
||||
case ten
|
||||
case eleven
|
||||
|
||||
var cMouseButton: ghostty_input_mouse_button_e {
|
||||
switch self {
|
||||
case .unknown: GHOSTTY_MOUSE_UNKNOWN
|
||||
case .left: GHOSTTY_MOUSE_LEFT
|
||||
case .right: GHOSTTY_MOUSE_RIGHT
|
||||
case .middle: GHOSTTY_MOUSE_MIDDLE
|
||||
case .four: GHOSTTY_MOUSE_FOUR
|
||||
case .five: GHOSTTY_MOUSE_FIVE
|
||||
case .six: GHOSTTY_MOUSE_SIX
|
||||
case .seven: GHOSTTY_MOUSE_SEVEN
|
||||
case .eight: GHOSTTY_MOUSE_EIGHT
|
||||
case .nine: GHOSTTY_MOUSE_NINE
|
||||
case .ten: GHOSTTY_MOUSE_TEN
|
||||
case .eleven: GHOSTTY_MOUSE_ELEVEN
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize from NSEvent.buttonNumber
|
||||
/// NSEvent buttonNumber: 0=left, 1=right, 2=middle, 3=back (button 8), 4=forward (button 9), etc.
|
||||
init(fromNSEventButtonNumber buttonNumber: Int) {
|
||||
switch buttonNumber {
|
||||
case 0: self = .left
|
||||
case 1: self = .right
|
||||
case 2: self = .middle
|
||||
case 3: self = .eight // Back button
|
||||
case 4: self = .nine // Forward button
|
||||
case 5: self = .six
|
||||
case 6: self = .seven
|
||||
case 7: self = .four
|
||||
case 8: self = .five
|
||||
case 9: self = .ten
|
||||
case 10: self = .eleven
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,18 +443,18 @@ extension Ghostty.Input {
|
||||
/// for scroll events, matching the Zig `ScrollMods` packed struct.
|
||||
struct ScrollMods {
|
||||
let rawValue: Int32
|
||||
|
||||
|
||||
/// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)
|
||||
var precision: Bool {
|
||||
rawValue & 0b0000_0001 != 0
|
||||
}
|
||||
|
||||
|
||||
/// The momentum phase of the scroll event for inertial scrolling
|
||||
var momentum: Momentum {
|
||||
let momentumBits = (rawValue >> 1) & 0b0000_0111
|
||||
return Momentum(rawValue: UInt8(momentumBits)) ?? .none
|
||||
}
|
||||
|
||||
|
||||
init(precision: Bool = false, momentum: Momentum = .none) {
|
||||
var value: Int32 = 0
|
||||
if precision {
|
||||
@@ -398,11 +463,11 @@ extension Ghostty.Input {
|
||||
value |= Int32(momentum.rawValue) << 1
|
||||
self.rawValue = value
|
||||
}
|
||||
|
||||
|
||||
init(rawValue: Int32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
|
||||
var cScrollMods: ghostty_input_scroll_mods_t {
|
||||
rawValue
|
||||
}
|
||||
@@ -421,7 +486,7 @@ extension Ghostty.Input {
|
||||
case ended = 4
|
||||
case cancelled = 5
|
||||
case mayBegin = 6
|
||||
|
||||
|
||||
var cMomentum: ghostty_input_mouse_momentum_e {
|
||||
switch self {
|
||||
case .none: GHOSTTY_MOUSE_MOMENTUM_NONE
|
||||
@@ -438,7 +503,7 @@ extension Ghostty.Input {
|
||||
|
||||
extension Ghostty.Input.Momentum: AppEnum {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum")
|
||||
|
||||
|
||||
static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [
|
||||
.none: "None",
|
||||
.began: "Began",
|
||||
@@ -475,7 +540,7 @@ extension Ghostty.Input {
|
||||
/// `ghostty_input_mods_e`
|
||||
struct Mods: OptionSet {
|
||||
let rawValue: UInt32
|
||||
|
||||
|
||||
static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
|
||||
static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
|
||||
static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
|
||||
@@ -486,23 +551,23 @@ extension Ghostty.Input {
|
||||
static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
|
||||
static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
|
||||
static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
|
||||
|
||||
|
||||
var cMods: ghostty_input_mods_e {
|
||||
ghostty_input_mods_e(rawValue)
|
||||
}
|
||||
|
||||
|
||||
init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
|
||||
init(cMods: ghostty_input_mods_e) {
|
||||
self.rawValue = cMods.rawValue
|
||||
}
|
||||
|
||||
|
||||
init(nsFlags: NSEvent.ModifierFlags) {
|
||||
self.init(cMods: Ghostty.ghosttyMods(nsFlags))
|
||||
}
|
||||
|
||||
|
||||
var nsFlags: NSEvent.ModifierFlags {
|
||||
Ghostty.eventModifierFlags(mods: cMods)
|
||||
}
|
||||
@@ -1116,43 +1181,43 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
return [
|
||||
// Letters (A-Z)
|
||||
.a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
|
||||
|
||||
|
||||
// Numbers (0-9)
|
||||
.digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
|
||||
|
||||
|
||||
// Common Control Keys
|
||||
.space, .enter, .tab, .backspace, .escape, .delete,
|
||||
|
||||
|
||||
// Arrow Keys
|
||||
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
|
||||
|
||||
|
||||
// Navigation Keys
|
||||
.home, .end, .pageUp, .pageDown, .insert,
|
||||
|
||||
|
||||
// Function Keys (F1-F20)
|
||||
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
|
||||
.f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
|
||||
|
||||
|
||||
// Modifier Keys
|
||||
.shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
|
||||
.metaLeft, .metaRight, .capsLock,
|
||||
|
||||
|
||||
// Punctuation & Symbols
|
||||
.minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
|
||||
.semicolon, .quote, .comma, .period, .slash,
|
||||
|
||||
|
||||
// Numpad
|
||||
.numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
|
||||
.numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
|
||||
.numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
|
||||
.numpadEnter, .numpadComma,
|
||||
|
||||
|
||||
// Media Keys
|
||||
.audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
|
||||
|
||||
|
||||
// International Keys
|
||||
.intlBackslash, .intlRo, .intlYen,
|
||||
|
||||
|
||||
// Other
|
||||
.contextMenu
|
||||
]
|
||||
@@ -1163,11 +1228,11 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
|
||||
.k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
|
||||
.u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
|
||||
|
||||
|
||||
// Numbers (0-9)
|
||||
.digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
|
||||
.digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
|
||||
|
||||
|
||||
// Common Control Keys
|
||||
.space: "Space",
|
||||
.enter: "Enter",
|
||||
@@ -1175,26 +1240,26 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.backspace: "Backspace",
|
||||
.escape: "Escape",
|
||||
.delete: "Delete",
|
||||
|
||||
|
||||
// Arrow Keys
|
||||
.arrowUp: "Up Arrow",
|
||||
.arrowDown: "Down Arrow",
|
||||
.arrowLeft: "Left Arrow",
|
||||
.arrowRight: "Right Arrow",
|
||||
|
||||
|
||||
// Navigation Keys
|
||||
.home: "Home",
|
||||
.end: "End",
|
||||
.pageUp: "Page Up",
|
||||
.pageDown: "Page Down",
|
||||
.insert: "Insert",
|
||||
|
||||
|
||||
// Function Keys (F1-F20)
|
||||
.f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
|
||||
.f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
|
||||
.f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
|
||||
.f18: "F18", .f19: "F19", .f20: "F20",
|
||||
|
||||
|
||||
// Modifier Keys
|
||||
.shiftLeft: "Left Shift",
|
||||
.shiftRight: "Right Shift",
|
||||
@@ -1205,7 +1270,7 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.metaLeft: "Left Command",
|
||||
.metaRight: "Right Command",
|
||||
.capsLock: "Caps Lock",
|
||||
|
||||
|
||||
// Punctuation & Symbols
|
||||
.minus: "Minus (-)",
|
||||
.equal: "Equal (=)",
|
||||
@@ -1218,7 +1283,7 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.comma: "Comma (,)",
|
||||
.period: "Period (.)",
|
||||
.slash: "Slash (/)",
|
||||
|
||||
|
||||
// Numpad
|
||||
.numLock: "Num Lock",
|
||||
.numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
|
||||
@@ -1232,17 +1297,17 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.numpadEqual: "Numpad Equal",
|
||||
.numpadEnter: "Numpad Enter",
|
||||
.numpadComma: "Numpad Comma",
|
||||
|
||||
|
||||
// Media Keys
|
||||
.audioVolumeUp: "Volume Up",
|
||||
.audioVolumeDown: "Volume Down",
|
||||
.audioVolumeMute: "Volume Mute",
|
||||
|
||||
|
||||
// International Keys
|
||||
.intlBackslash: "International Backslash",
|
||||
.intlRo: "International Ro",
|
||||
.intlYen: "International Yen",
|
||||
|
||||
|
||||
// Other
|
||||
.contextMenu: "Context Menu"
|
||||
]
|
||||
|
||||
@@ -62,6 +62,26 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a key event matches a keybinding.
|
||||
///
|
||||
/// This checks whether the given key event would trigger a keybinding in the terminal.
|
||||
/// If it matches, returns the binding flags indicating properties of the matched binding.
|
||||
///
|
||||
/// - Parameter event: The key event to check
|
||||
/// - Returns: The binding flags if a binding matches, or nil if no binding matches
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: ghostty_input_key_s) -> Input.BindingFlags? {
|
||||
var flags = ghostty_binding_flags_e(0)
|
||||
guard ghostty_surface_key_is_binding(surface, event, &flags) else { return nil }
|
||||
return Input.BindingFlags(cFlags: flags)
|
||||
}
|
||||
|
||||
/// See `keyIsBinding(_ event: ghostty_input_key_s)`.
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: Input.KeyEvent) -> Input.BindingFlags? {
|
||||
event.withCValue { keyIsBinding($0) }
|
||||
}
|
||||
|
||||
/// Whether the terminal has captured mouse input.
|
||||
///
|
||||
/// When the mouse is captured, the terminal application is receiving mouse events
|
||||
@@ -134,16 +154,5 @@ extension Ghostty {
|
||||
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Command options for this surface.
|
||||
@MainActor
|
||||
func commands() throws -> [Command] {
|
||||
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
|
||||
var count: Int = 0
|
||||
ghostty_surface_commands(surface, &ptr, &count)
|
||||
guard let ptr else { throw Error.apiFailed }
|
||||
let buffer = UnsafeBufferPointer(start: ptr, count: count)
|
||||
return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension Ghostty {
|
||||
/// This is a delegate that should be applied to your global app delegate for GhosttyKit
|
||||
/// to perform app-global operations.
|
||||
protocol Delegate {
|
||||
/// Look up a surface within the application by ID.
|
||||
func ghosttySurface(id: UUID) -> SurfaceView?
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ extension Ghostty {
|
||||
case app
|
||||
case zig_run
|
||||
}
|
||||
|
||||
|
||||
/// Returns the mechanism that launched the app. This is based on an env var so
|
||||
/// its up to the env var being set in the correct circumstance.
|
||||
static var launchSource: LaunchSource {
|
||||
@@ -65,7 +65,7 @@ extension Ghostty {
|
||||
// source. If its unset we assume we're in a CLI environment.
|
||||
return .cli
|
||||
}
|
||||
|
||||
|
||||
// If the env var is set but its unknown then we default back to the app.
|
||||
return LaunchSource(rawValue: envValue) ?? .app
|
||||
}
|
||||
@@ -76,17 +76,17 @@ extension Ghostty {
|
||||
extension Ghostty {
|
||||
class AllocatedString {
|
||||
private let cString: ghostty_string_s
|
||||
|
||||
|
||||
init(_ c: ghostty_string_s) {
|
||||
self.cString = c
|
||||
}
|
||||
|
||||
|
||||
var string: String {
|
||||
guard let ptr = cString.ptr else { return "" }
|
||||
let data = Data(bytes: ptr, count: Int(cString.len))
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
ghostty_string_free(cString)
|
||||
}
|
||||
@@ -330,6 +330,22 @@ extension Ghostty {
|
||||
case xray
|
||||
case custom
|
||||
case customStyle = "custom-style"
|
||||
|
||||
/// Bundled asset name for built-in icons
|
||||
var assetName: String? {
|
||||
switch self {
|
||||
case .official: return nil
|
||||
case .blueprint: return "BlueprintImage"
|
||||
case .chalkboard: return "ChalkboardImage"
|
||||
case .microchip: return "MicrochipImage"
|
||||
case .glass: return "GlassImage"
|
||||
case .holographic: return "HolographicImage"
|
||||
case .paper: return "PaperImage"
|
||||
case .retro: return "RetroImage"
|
||||
case .xray: return "XrayImage"
|
||||
case .custom, .customStyle: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macos-icon-frame
|
||||
@@ -380,6 +396,9 @@ extension Notification.Name {
|
||||
/// Close other tabs
|
||||
static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs")
|
||||
|
||||
/// Close tabs to the right of the focused tab
|
||||
static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight")
|
||||
|
||||
/// Close window
|
||||
static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow")
|
||||
|
||||
@@ -388,6 +407,10 @@ extension Notification.Name {
|
||||
|
||||
/// Ring the bell
|
||||
static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing")
|
||||
|
||||
/// Readonly mode changed
|
||||
static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly")
|
||||
static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly"
|
||||
static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle")
|
||||
|
||||
/// Toggle maximize of current window
|
||||
@@ -428,6 +451,9 @@ extension Ghostty.Notification {
|
||||
/// New window. Has base surface config requested in userinfo.
|
||||
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")
|
||||
|
||||
/// Present terminal. Bring the surface's window to focus without activating the app.
|
||||
static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal")
|
||||
|
||||
/// Toggle fullscreen of current window
|
||||
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
|
||||
static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue
|
||||
@@ -465,6 +491,10 @@ extension Ghostty.Notification {
|
||||
static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence")
|
||||
static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence")
|
||||
static let KeySequenceKey = didContinueKeySequence.rawValue + ".key"
|
||||
|
||||
/// Notifications related to key tables
|
||||
static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable")
|
||||
static let KeyTableKey = didChangeKeyTable.rawValue + ".action"
|
||||
}
|
||||
|
||||
// Make the input enum hashable.
|
||||
|
||||
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A preference key that propagates the ID of the SurfaceView currently being dragged,
|
||||
/// or nil if no surface is being dragged.
|
||||
struct DraggingSurfaceKey: PreferenceKey {
|
||||
static var defaultValue: SurfaceView.ID? = nil
|
||||
|
||||
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
|
||||
///
|
||||
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
|
||||
/// of terminal surfaces within split views. When the user drags this view, it initiates
|
||||
/// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing
|
||||
/// drop targets to identify which surface is being moved.
|
||||
///
|
||||
/// The view also publishes the dragging state via `DraggingSurfaceKey` preference,
|
||||
/// enabling parent views to react to ongoing drag operations.
|
||||
struct SurfaceDragSource: View {
|
||||
/// The surface view that will be dragged.
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
/// Binding that reflects whether a drag session is currently active.
|
||||
@Binding var isDragging: Bool
|
||||
|
||||
/// Binding that reflects whether the mouse is hovering over this view.
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
var body: some View {
|
||||
SurfaceDragSourceViewRepresentable(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering)
|
||||
.preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// An NSViewRepresentable that provides AppKit-based drag source functionality.
|
||||
/// This gives us control over the drag lifecycle, particularly detecting drag start.
|
||||
fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable {
|
||||
let surfaceView: SurfaceView
|
||||
@Binding var isDragging: Bool
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
func makeNSView(context: Context) -> SurfaceDragSourceView {
|
||||
let view = SurfaceDragSourceView()
|
||||
view.surfaceView = surfaceView
|
||||
view.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
view.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
|
||||
nsView.surfaceView = surfaceView
|
||||
nsView.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
nsView.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying NSView that handles drag operations.
|
||||
///
|
||||
/// This view manages mouse tracking and drag initiation for surface reordering.
|
||||
/// It uses a local event loop to detect drag gestures and initiates an
|
||||
/// `NSDraggingSession` when the user drags beyond the threshold distance.
|
||||
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
|
||||
/// Scale factor applied to the surface snapshot for the drag preview image.
|
||||
private static let previewScale: CGFloat = 0.2
|
||||
|
||||
/// The surface view that will be dragged. Its UUID is encoded into the
|
||||
/// pasteboard for drop targets to identify which surface is being moved.
|
||||
var surfaceView: SurfaceView?
|
||||
|
||||
/// Callback invoked when the drag state changes. Called with `true` when
|
||||
/// a drag session begins, and `false` when it ends (completed or cancelled).
|
||||
var onDragStateChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Callback invoked when the mouse enters or exits this view's bounds.
|
||||
/// Used to update the hover state for visual feedback in the parent view.
|
||||
var onHoverChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Whether we are currently in a mouse tracking loop (between mouseDown
|
||||
/// and either mouseUp or drag initiation). Used to determine cursor state.
|
||||
private var isTracking: Bool = false
|
||||
|
||||
/// Local event monitor to detect escape key presses during drag.
|
||||
private var escapeMonitor: Any?
|
||||
|
||||
/// Whether the current drag was cancelled by pressing escape.
|
||||
private var dragCancelledByEscape: Bool = false
|
||||
|
||||
deinit {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
// Ensure this view gets the mouse event before window dragging handlers
|
||||
return true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
// Consume the mouseDown event to prevent it from propagating to the
|
||||
// window's drag handler. This fixes issue #10110 where grab handles
|
||||
// would drag the window instead of initiating pane drags.
|
||||
// Don't call super - the drag will be initiated in mouseDragged.
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
|
||||
// To update our tracking area we just recreate it all.
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
|
||||
// Add our tracking area for mouse events
|
||||
addTrackingArea(NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: [.mouseEnteredAndExited, .activeInActiveApp],
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
))
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
onHoverChanged?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
onHoverChanged?(false)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard !isTracking, let surfaceView = surfaceView else { return }
|
||||
|
||||
// Create our dragging item from our transferable
|
||||
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
|
||||
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
|
||||
// Create a scaled preview image from the surface snapshot
|
||||
if let snapshot = surfaceView.asImage {
|
||||
let imageSize = NSSize(
|
||||
width: snapshot.size.width * Self.previewScale,
|
||||
height: snapshot.size.height * Self.previewScale
|
||||
)
|
||||
let scaledImage = NSImage(size: imageSize)
|
||||
scaledImage.lockFocus()
|
||||
snapshot.draw(
|
||||
in: NSRect(origin: .zero, size: imageSize),
|
||||
from: NSRect(origin: .zero, size: snapshot.size),
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
)
|
||||
scaledImage.unlockFocus()
|
||||
|
||||
// Position the drag image so the mouse is at the center of the image.
|
||||
// I personally like the top middle or top left corner best but
|
||||
// this matches macOS native tab dragging behavior (at least, as of
|
||||
// macOS 26.2 on Dec 29, 2025).
|
||||
let mouseLocation = convert(event.locationInWindow, from: nil)
|
||||
let origin = NSPoint(
|
||||
x: mouseLocation.x - imageSize.width / 2,
|
||||
y: mouseLocation.y - imageSize.height / 2
|
||||
)
|
||||
item.setDraggingFrame(
|
||||
NSRect(origin: origin, size: imageSize),
|
||||
contents: scaledImage
|
||||
)
|
||||
}
|
||||
|
||||
onDragStateChanged?(true)
|
||||
let session = beginDraggingSession(with: [item], event: event, source: self)
|
||||
|
||||
// We need to disable this so that endedAt happens immediately for our
|
||||
// drags outside of any targets.
|
||||
session.animatesToStartingPositionsOnCancelOrFail = false
|
||||
}
|
||||
|
||||
// MARK: NSDraggingSource
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
sourceOperationMaskFor context: NSDraggingContext
|
||||
) -> NSDragOperation {
|
||||
return context == .withinApplication ? .move : []
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
willBeginAt screenPoint: NSPoint
|
||||
) {
|
||||
isTracking = true
|
||||
|
||||
// Reset our escape tracking
|
||||
dragCancelledByEscape = false
|
||||
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
if event.keyCode == 53 { // Escape key
|
||||
self?.dragCancelledByEscape = true
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
movedTo screenPoint: NSPoint
|
||||
) {
|
||||
NSCursor.closedHand.set()
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
endedAt screenPoint: NSPoint,
|
||||
operation: NSDragOperation
|
||||
) {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
self.escapeMonitor = nil
|
||||
}
|
||||
|
||||
if operation == [] && !dragCancelledByEscape {
|
||||
let endsInWindow = NSApplication.shared.windows.contains { window in
|
||||
window.isVisible && window.frame.contains(screenPoint)
|
||||
}
|
||||
if !endsInWindow {
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: surfaceView,
|
||||
userInfo: [Foundation.Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey: screenPoint]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isTracking = false
|
||||
onDragStateChanged?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when a surface drag session ends with no operation (the drag was
|
||||
/// released outside a valid drop target) and was not cancelled by the user
|
||||
/// pressing escape. The notification's object is the SurfaceView that was dragged.
|
||||
static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget")
|
||||
|
||||
/// Key for the screen point where the drag ended in the userInfo dictionary.
|
||||
static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint"
|
||||
}
|
||||
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A grab handle overlay at the top of the surface for dragging the window.
|
||||
/// Only appears when hovering in the top region of the surface.
|
||||
struct SurfaceGrabHandle: View {
|
||||
private let handleHeight: CGFloat = 10
|
||||
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var isDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0))
|
||||
.frame(height: handleHeight)
|
||||
.overlay(alignment: .center) {
|
||||
if isHovering || isDragging {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
SurfaceDragSource(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,15 @@ class SurfaceScrollView: NSView {
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = false
|
||||
scrollView.usesPredominantAxisScrolling = true
|
||||
// Always use the overlay style. See mouseMoved for how we make
|
||||
// it usable without a scroll wheel or gestures.
|
||||
scrollView.scrollerStyle = .overlay
|
||||
// hide default background to show blur effect properly
|
||||
scrollView.drawsBackground = false
|
||||
// don't let the content view clip it's subviews, to enable the
|
||||
// don't let the content view clip its subviews, to enable the
|
||||
// surface to draw the background behind non-overlay scrollers
|
||||
// (we currently only use overlay scrollers, but might as well
|
||||
// configure the views correctly in case we change our mind)
|
||||
scrollView.contentView.clipsToBounds = false
|
||||
|
||||
// The document view is what the scrollview is actually going
|
||||
@@ -107,24 +112,29 @@ class SurfaceScrollView: NSView {
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.handleScrollerStyleChange()
|
||||
})
|
||||
|
||||
// Listen for frame change events. See the docstring for
|
||||
// handleFrameChange for why this is necessary.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChange(notification)
|
||||
) { [weak self] _ in
|
||||
self?.handleScrollerStyleChange()
|
||||
})
|
||||
|
||||
// Listen for frame change events on macOS 26.0. See the docstring for
|
||||
// handleFrameChangeForNSScrollPocket for why this is necessary.
|
||||
if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) {
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChangeForNSScrollPocket(notification)
|
||||
})
|
||||
}}
|
||||
|
||||
// Listen for derived config changes to update scrollbar settings live
|
||||
surfaceView.$derivedConfig
|
||||
.sink { [weak self] _ in
|
||||
@@ -176,10 +186,10 @@ class SurfaceScrollView: NSView {
|
||||
private func synchronizeAppearance() {
|
||||
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
|
||||
scrollView.hasVerticalScroller = scrollbarConfig != .never
|
||||
scrollView.verticalScroller?.controlSize = .small
|
||||
let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
|
||||
// Make sure the scroller’s appearance matches the surface's background color.
|
||||
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
|
||||
updateTrackingAreas()
|
||||
}
|
||||
|
||||
/// Positions the surface view to fill the currently visible rectangle.
|
||||
@@ -240,6 +250,7 @@ class SurfaceScrollView: NSView {
|
||||
|
||||
/// Handles scrollbar style changes
|
||||
private func handleScrollerStyleChange() {
|
||||
scrollView.scrollerStyle = .overlay
|
||||
synchronizeCoreSurface()
|
||||
}
|
||||
|
||||
@@ -319,7 +330,10 @@ class SurfaceScrollView: NSView {
|
||||
/// and reset their frame to zero.
|
||||
///
|
||||
/// See also https://developer.apple.com/forums/thread/798392.
|
||||
private func handleFrameChange(_ notification: Notification) {
|
||||
///
|
||||
/// This bug is only present in macOS 26.0.
|
||||
@available(macOS, introduced: 26.0, obsoleted: 26.1)
|
||||
private func handleFrameChangeForNSScrollPocket(_ notification: Notification) {
|
||||
guard let window = window as? HiddenTitlebarTerminalWindow else { return }
|
||||
guard !window.styleMask.contains(.fullScreen) else { return }
|
||||
guard let view = notification.object as? NSView else { return }
|
||||
@@ -350,4 +364,32 @@ class SurfaceScrollView: NSView {
|
||||
}
|
||||
return contentHeight
|
||||
}
|
||||
|
||||
// MARK: Mouse events
|
||||
|
||||
override func mouseMoved(with: NSEvent) {
|
||||
// When the OS preferred style is .legacy, the user should be able to
|
||||
// click and drag the scroller without using scroll wheels or gestures,
|
||||
// so we flash it when the mouse is moved over the scrollbar area.
|
||||
guard NSScroller.preferredScrollerStyle == .legacy else { return }
|
||||
scrollView.flashScrollers()
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
// To update our tracking area we just recreate it all.
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
|
||||
super.updateTrackingAreas()
|
||||
|
||||
// Our tracking area is the scroller frame
|
||||
guard let scroller = scrollView.verticalScroller else { return }
|
||||
addTrackingArea(NSTrackingArea(
|
||||
rect: convert(scroller.bounds, from: scroller),
|
||||
options: [
|
||||
.mouseMoved,
|
||||
.activeInKeyWindow,
|
||||
],
|
||||
owner: self,
|
||||
userInfo: nil))
|
||||
}
|
||||
}
|
||||
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Ghostty.SurfaceView {
|
||||
#if canImport(AppKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: NSImage? {
|
||||
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
|
||||
return nil
|
||||
}
|
||||
cacheDisplay(in: bounds, to: bitmapRep)
|
||||
let image = NSImage(size: bounds.size)
|
||||
image.addRepresentation(bitmapRep)
|
||||
return image
|
||||
}
|
||||
#elseif canImport(UIKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: UIImage? {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
return renderer.image { _ in
|
||||
drawHierarchy(in: bounds, afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Conformance to `Transferable` enables drag-and-drop.
|
||||
extension Ghostty.SurfaceView: Transferable {
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .ghosttySurfaceId) { surface in
|
||||
withUnsafeBytes(of: surface.id.uuid) { Data($0) }
|
||||
} importing: { data in
|
||||
guard data.count == 16 else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
let uuid = data.withUnsafeBytes {
|
||||
$0.load(as: UUID.self)
|
||||
}
|
||||
|
||||
guard let imported = await Self.find(uuid: uuid) else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
return imported
|
||||
}
|
||||
}
|
||||
|
||||
enum TransferError: Error {
|
||||
case invalidData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func find(uuid: UUID) -> Self? {
|
||||
#if canImport(AppKit)
|
||||
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
|
||||
return del.ghosttySurface(id: uuid) as? Self
|
||||
#elseif canImport(UIKit)
|
||||
// We should be able to use UIApplication here.
|
||||
return nil
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension UTType {
|
||||
/// A format that encodes the bare UUID only for the surface. This can be used if you have
|
||||
/// a way to look up a surface by ID.
|
||||
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
extension NSPasteboard.PasteboardType {
|
||||
/// Pasteboard type for dragging surface IDs.
|
||||
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
|
||||
}
|
||||
#endif
|
||||
@@ -49,7 +49,7 @@ extension Ghostty {
|
||||
|
||||
// True if we're hovering over the left URL view, so we can show it on the right.
|
||||
@State private var isHoveringURLLeft: Bool = false
|
||||
|
||||
|
||||
#if canImport(AppKit)
|
||||
// Observe SecureInput to detect when its enabled
|
||||
@ObservedObject private var secureInput = SecureInput.shared
|
||||
@@ -116,31 +116,18 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
// If we are in the middle of a key sequence, then we show a visual element. We only
|
||||
// support this on macOS currently although in theory we can support mobile with keyboards!
|
||||
if !surfaceView.keySequence.isEmpty {
|
||||
let padding: CGFloat = 5
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Text(verbatim: "Pending Key Sequence:")
|
||||
ForEach(0..<surfaceView.keySequence.count, id: \.description) { index in
|
||||
let key = surfaceView.keySequence[index]
|
||||
Text(verbatim: key.description)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color(NSColor.selectedTextBackgroundColor))
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.background)
|
||||
// Readonly indicator badge
|
||||
if surfaceView.readonly {
|
||||
ReadonlyBadge {
|
||||
surfaceView.toggleReadonly(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Show key state indicator for active key tables and/or pending key sequences
|
||||
KeyStateIndicator(
|
||||
keyTables: surfaceView.keyTables,
|
||||
keySequence: surfaceView.keySequence
|
||||
)
|
||||
#endif
|
||||
|
||||
// If we have a URL from hovering a link, we show that.
|
||||
@@ -203,7 +190,12 @@ extension Ghostty {
|
||||
SurfaceSearchOverlay(
|
||||
surfaceView: surfaceView,
|
||||
searchState: searchState,
|
||||
onClose: { surfaceView.searchState = nil }
|
||||
onClose: {
|
||||
#if canImport(AppKit)
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
#endif
|
||||
surfaceView.searchState = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,6 +204,9 @@ extension Ghostty {
|
||||
BellBorderOverlay(bell: surfaceView.bell)
|
||||
}
|
||||
|
||||
// Show a highlight effect when this surface needs attention
|
||||
HighlightOverlay(highlighted: surfaceView.highlighted)
|
||||
|
||||
// If our surface is not healthy, then we render an error view over it.
|
||||
if (!surfaceView.healthy) {
|
||||
Rectangle().fill(ghostty.config.backgroundColor)
|
||||
@@ -234,7 +229,16 @@ extension Ghostty {
|
||||
.opacity(overlayOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
// Grab handle for dragging the window. We want this to appear at the very
|
||||
// top Z-index os it isn't faded by the unfocused overlay.
|
||||
//
|
||||
// This is disabled except on macOS because it uses AppKit drag/drop APIs.
|
||||
SurfaceGrabHandle(surfaceView: surfaceView)
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +436,11 @@ extension Ghostty {
|
||||
}
|
||||
#if canImport(AppKit)
|
||||
.onExitCommand {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
if searchState.needle.isEmpty {
|
||||
onClose()
|
||||
} else {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.backport.onKeyPress(.return) { modifiers in
|
||||
@@ -476,7 +484,9 @@ extension Ghostty {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
|
||||
guard notification.object as? SurfaceView === surfaceView else { return }
|
||||
isSearchFieldFocused = true
|
||||
DispatchQueue.main.async {
|
||||
isSearchFieldFocused = true
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { barGeo in
|
||||
@@ -647,6 +657,9 @@ extension Ghostty {
|
||||
/// Wait after the command
|
||||
var waitAfterCommand: Bool = false
|
||||
|
||||
/// Context for surface creation
|
||||
var context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_WINDOW
|
||||
|
||||
init() {}
|
||||
|
||||
init(from config: ghostty_surface_config_s) {
|
||||
@@ -668,6 +681,7 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.context = config.context
|
||||
}
|
||||
|
||||
/// Provides a C-compatible ghostty configuration within a closure. The configuration
|
||||
@@ -701,6 +715,9 @@ extension Ghostty {
|
||||
// Set wait after command
|
||||
config.wait_after_command = waitAfterCommand
|
||||
|
||||
// Set context
|
||||
config.context = context
|
||||
|
||||
// Use withCString to ensure strings remain valid for the duration of the closure
|
||||
return try workingDirectory.withCString { cWorkingDir in
|
||||
config.working_directory = cWorkingDir
|
||||
@@ -741,6 +758,226 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
/// Floating indicator that shows active key tables and pending key sequences.
|
||||
/// Displayed as a compact draggable pill that can be positioned at the top or bottom.
|
||||
struct KeyStateIndicator: View {
|
||||
let keyTables: [String]
|
||||
let keySequence: [KeyboardShortcut]
|
||||
|
||||
@State private var isShowingPopover = false
|
||||
@State private var position: Position = .bottom
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
@State private var isDragging = false
|
||||
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
enum Position {
|
||||
case top, bottom
|
||||
|
||||
var alignment: Alignment {
|
||||
switch self {
|
||||
case .top: return .top
|
||||
case .bottom: return .bottom
|
||||
}
|
||||
}
|
||||
|
||||
var popoverEdge: Edge {
|
||||
switch self {
|
||||
case .top: return .top
|
||||
case .bottom: return .bottom
|
||||
}
|
||||
}
|
||||
|
||||
var transitionEdge: Edge {
|
||||
popoverEdge
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !keyTables.isEmpty || !keySequence.isEmpty {
|
||||
content
|
||||
.backport.pointerStyle(!keyTables.isEmpty ? .link : nil)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: position.transitionEdge).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count)
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
indicatorContent
|
||||
.offset(dragOffset)
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment)
|
||||
.highPriorityGesture(
|
||||
DragGesture(coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
dragOffset = CGSize(width: 0, height: value.translation.height)
|
||||
}
|
||||
.onEnded { value in
|
||||
isDragging = false
|
||||
let dragThreshold: CGFloat = 50
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
if position == .bottom && value.translation.height < -dragThreshold {
|
||||
position = .top
|
||||
} else if position == .top && value.translation.height > dragThreshold {
|
||||
position = .bottom
|
||||
}
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorContent: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
// Key table indicator
|
||||
if !keyTables.isEmpty {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "keyboard.badge.ellipsis")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Show table stack with arrows between them
|
||||
ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in
|
||||
if index > 0 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(verbatim: table)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator when both are active
|
||||
if !keyTables.isEmpty && !keySequence.isEmpty {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
}
|
||||
|
||||
// Key sequence indicator
|
||||
if !keySequence.isEmpty {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in
|
||||
KeyCap(key.description)
|
||||
}
|
||||
|
||||
// Animated ellipsis to indicate waiting for next key
|
||||
PendingIndicator(paused: isDragging)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
.backport.pointerStyle(.link)
|
||||
.popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !keyTables.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Key Table", systemImage: "keyboard.badge.ellipsis")
|
||||
.font(.headline)
|
||||
Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !keyTables.isEmpty && !keySequence.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
|
||||
if !keySequence.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Key Sequence", systemImage: "character.cursor.ibeam")
|
||||
.font(.headline)
|
||||
Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: 400)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.onTapGesture {
|
||||
isShowingPopover.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/// A small keycap-style view for displaying keyboard shortcuts
|
||||
struct KeyCap: View {
|
||||
let text: String
|
||||
|
||||
init(_ text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: text)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated dots to indicate waiting for the next key
|
||||
struct PendingIndicator: View {
|
||||
@State private var animationPhase: Double = 0
|
||||
let paused: Bool
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: paused)) { context in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(Color.secondary)
|
||||
.frame(width: 4, height: 4)
|
||||
.opacity(dotOpacity(for: index))
|
||||
}
|
||||
}
|
||||
.onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in
|
||||
animationPhase = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dotOpacity(for index: Int) -> Double {
|
||||
let phase = animationPhase
|
||||
let offset = Double(index) / 3.0
|
||||
let wave = sin((phase + offset) * .pi * 2)
|
||||
return 0.3 + 0.7 * ((wave + 1) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
|
||||
struct BellBorderOverlay: View {
|
||||
let bell: Bool
|
||||
@@ -757,6 +994,152 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual overlay that briefly highlights a surface to draw attention to it.
|
||||
/// Uses a soft, soothing highlight with a pulsing border effect.
|
||||
struct HighlightOverlay: View {
|
||||
let highlighted: Bool
|
||||
|
||||
@State private var borderPulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.accentColor.opacity(0.12),
|
||||
Color.accentColor.opacity(0.03),
|
||||
Color.clear
|
||||
]),
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 2000
|
||||
)
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.accentColor.opacity(0.8),
|
||||
Color.accentColor.opacity(0.5),
|
||||
Color.accentColor.opacity(0.8)
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: borderPulse ? 4 : 2
|
||||
)
|
||||
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0)
|
||||
.shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.opacity(highlighted ? 1.0 : 0.0)
|
||||
.animation(.easeOut(duration: 0.4), value: highlighted)
|
||||
.onChange(of: highlighted) { newValue in
|
||||
if newValue {
|
||||
withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) {
|
||||
borderPulse = true
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
borderPulse = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Readonly Badge
|
||||
|
||||
/// A badge overlay that indicates a surface is in readonly mode.
|
||||
/// Positioned in the top-right corner and styled to be noticeable but unobtrusive.
|
||||
struct ReadonlyBadge: View {
|
||||
let onDisable: () -> Void
|
||||
|
||||
@State private var showingPopover = false
|
||||
|
||||
private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8)
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "eye.fill")
|
||||
.font(.system(size: 12))
|
||||
Text("Read-only")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(badgeBackground)
|
||||
.foregroundStyle(badgeColor)
|
||||
.onTapGesture {
|
||||
showingPopover = true
|
||||
}
|
||||
.backport.pointerStyle(.link)
|
||||
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
|
||||
ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Read-only terminal")
|
||||
}
|
||||
|
||||
private var badgeBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadonlyPopoverView: View {
|
||||
let onDisable: () -> Void
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eye.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 13))
|
||||
Text("Read-Only Mode")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Disable") {
|
||||
onDisable()
|
||||
isPresented = false
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
/// When changing the split state, or going full screen (native or non), the terminal view
|
||||
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
|
||||
@@ -9,7 +9,7 @@ extension Ghostty {
|
||||
/// The NSView implementation for a terminal surface.
|
||||
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
|
||||
typealias ID = UUID
|
||||
|
||||
|
||||
/// Unique ID per surface
|
||||
let id: UUID
|
||||
|
||||
@@ -44,14 +44,14 @@ extension Ghostty {
|
||||
|
||||
// The hovered URL string
|
||||
@Published var hoverUrl: String? = nil
|
||||
|
||||
|
||||
// The progress report (if any)
|
||||
@Published var progressReport: Action.ProgressReport? = nil {
|
||||
didSet {
|
||||
// Cancel any existing timer
|
||||
progressReportTimer?.invalidate()
|
||||
progressReportTimer = nil
|
||||
|
||||
|
||||
// If we have a new progress report, start a timer to remove it after 15 seconds
|
||||
if progressReport != nil {
|
||||
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
|
||||
@@ -65,6 +65,9 @@ extension Ghostty {
|
||||
// The currently active key sequence. The sequence is not active if this is empty.
|
||||
@Published var keySequence: [KeyboardShortcut] = []
|
||||
|
||||
// The currently active key tables. Empty if no tables are active.
|
||||
@Published var keyTables: [String] = []
|
||||
|
||||
// The current search state. When non-nil, the search overlay should be shown.
|
||||
@Published var searchState: SearchState? = nil {
|
||||
didSet {
|
||||
@@ -98,7 +101,7 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cancellable for search state needle changes
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
|
||||
@@ -123,6 +126,12 @@ extension Ghostty {
|
||||
/// True when the bell is active. This is set inactive on focus or event.
|
||||
@Published private(set) var bell: Bool = false
|
||||
|
||||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
|
||||
@Published private(set) var highlighted: Bool = false
|
||||
|
||||
// An initial size to request for a window. This will only affect
|
||||
// then the view is moved to a new window.
|
||||
var initialSize: NSSize? = nil
|
||||
@@ -210,7 +219,7 @@ extension Ghostty {
|
||||
|
||||
// A timer to fallback to ghost emoji if no title is set within the grace period
|
||||
private var titleFallbackTimer: Timer?
|
||||
|
||||
|
||||
// Timer to remove progress report after 15 seconds
|
||||
private var progressReportTimer: Timer?
|
||||
|
||||
@@ -318,6 +327,11 @@ extension Ghostty {
|
||||
selector: #selector(ghosttyDidEndKeySequence),
|
||||
name: Ghostty.Notification.didEndKeySequence,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidChangeKeyTable),
|
||||
name: Ghostty.Notification.didChangeKeyTable,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
@@ -333,6 +347,11 @@ extension Ghostty {
|
||||
selector: #selector(ghosttyBellDidRing(_:)),
|
||||
name: .ghosttyBellDidRing,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidChangeReadonly(_:)),
|
||||
name: .ghosttyDidChangeReadonly,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidChangeScreen),
|
||||
@@ -369,26 +388,6 @@ extension Ghostty {
|
||||
// Setup our tracking area so we get mouse moved events
|
||||
updateTrackingAreas()
|
||||
|
||||
// Observe our appearance so we can report the correct value to libghostty.
|
||||
// This is the best way I know of to get appearance change notifications.
|
||||
self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in
|
||||
guard let appearance = change.newValue else { return }
|
||||
guard let surface = view.surface else { return }
|
||||
let scheme: ghostty_color_scheme_e
|
||||
switch (appearance.name) {
|
||||
case .aqua, .vibrantLight:
|
||||
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
|
||||
|
||||
case .darkAqua, .vibrantDark:
|
||||
scheme = GHOSTTY_COLOR_SCHEME_DARK
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
ghostty_surface_set_color_scheme(surface, scheme)
|
||||
}
|
||||
|
||||
// The UTTypes that can be dragged onto this view.
|
||||
registerForDraggedTypes(Array(Self.dropTypes))
|
||||
}
|
||||
@@ -419,7 +418,7 @@ extension Ghostty {
|
||||
// Remove any notifications associated with this surface
|
||||
let identifiers = Array(self.notificationIdentifiers)
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
|
||||
|
||||
// Cancel progress report timer
|
||||
progressReportTimer?.invalidate()
|
||||
}
|
||||
@@ -556,16 +555,16 @@ extension Ghostty {
|
||||
// Add buttons
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
|
||||
// Make the text field the first responder so it gets focus
|
||||
alert.window.initialFirstResponder = textField
|
||||
|
||||
|
||||
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
// Check if the user clicked "OK"
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
|
||||
|
||||
// Get the input text
|
||||
let newTitle = textField.stringValue
|
||||
if newTitle.isEmpty {
|
||||
@@ -689,6 +688,22 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) {
|
||||
guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .activate(let name):
|
||||
self.keyTables.append(name)
|
||||
case .deactivate:
|
||||
_ = self.keyTables.popLast()
|
||||
case .deactivateAll:
|
||||
self.keyTables.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) {
|
||||
// Get our managed configuration object out
|
||||
guard let config = notification.userInfo?[
|
||||
@@ -723,6 +738,11 @@ extension Ghostty {
|
||||
bell = true
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) {
|
||||
guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return }
|
||||
readonly = value
|
||||
}
|
||||
|
||||
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
|
||||
guard let window = self.window else { return }
|
||||
guard let object = notification.object as? NSWindow, window == object else { return }
|
||||
@@ -840,16 +860,16 @@ extension Ghostty {
|
||||
|
||||
override func otherMouseDown(with event: NSEvent) {
|
||||
guard let surface = self.surface else { return }
|
||||
guard event.buttonNumber == 2 else { return }
|
||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods)
|
||||
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods)
|
||||
}
|
||||
|
||||
override func otherMouseUp(with event: NSEvent) {
|
||||
guard let surface = self.surface else { return }
|
||||
guard event.buttonNumber == 2 else { return }
|
||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods)
|
||||
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods)
|
||||
}
|
||||
|
||||
|
||||
@@ -968,7 +988,7 @@ extension Ghostty {
|
||||
var x = event.scrollingDeltaX
|
||||
var y = event.scrollingDeltaY
|
||||
let precision = event.hasPreciseScrollingDeltas
|
||||
|
||||
|
||||
if precision {
|
||||
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
|
||||
x *= 2;
|
||||
@@ -1161,17 +1181,10 @@ extension Ghostty {
|
||||
|
||||
/// Special case handling for some control keys
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
switch (event.type) {
|
||||
case .keyDown:
|
||||
// Continue, we care about key down events
|
||||
break
|
||||
|
||||
default:
|
||||
// Any other key event we don't care about. I don't think its even
|
||||
// possible to receive any other event type.
|
||||
return false
|
||||
}
|
||||
|
||||
// We only care about key down events. It might not even be possible
|
||||
// to receive any other event type here.
|
||||
guard event.type == .keyDown else { return false }
|
||||
|
||||
// Only process events if we're focused. Some key events like C-/ macOS
|
||||
// appears to send to the first view in the hierarchy rather than the
|
||||
// the first responder (I don't know why). This prevents us from handling it.
|
||||
@@ -1181,18 +1194,35 @@ extension Ghostty {
|
||||
if (!focused) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If this event as-is would result in a key binding then we send it.
|
||||
if let surface {
|
||||
|
||||
// Get information about if this is a binding.
|
||||
let bindingFlags = surfaceModel.flatMap { surface in
|
||||
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
let match = (event.characters ?? "").withCString { ptr in
|
||||
return (event.characters ?? "").withCString { ptr in
|
||||
ghosttyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(surface, ghosttyEvent)
|
||||
return surface.keyIsBinding(ghosttyEvent)
|
||||
}
|
||||
if match {
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
// If this is a binding then we want to perform it.
|
||||
if let bindingFlags {
|
||||
// Attempt to trigger a menu item for this key binding. We only do this if:
|
||||
// - We're not in a key sequence or table (those are separate bindings)
|
||||
// - The binding is NOT `all` (menu uses FirstResponder chain)
|
||||
// - The binding is NOT `performable` (menu will always consume)
|
||||
// - The binding is `consumed` (unconsumed bindings should pass through
|
||||
// to the terminal, so we must not intercept them for the menu)
|
||||
if keySequence.isEmpty,
|
||||
keyTables.isEmpty,
|
||||
bindingFlags.isDisjoint(with: [.all, .performable]),
|
||||
bindingFlags.contains(.consumed) {
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
@@ -1330,7 +1360,7 @@ extension Ghostty {
|
||||
|
||||
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
|
||||
key_ev.composing = composing
|
||||
|
||||
|
||||
// For text, we only encode UTF8 if we don't have a single control
|
||||
// character. Control characters are encoded by Ghostty itself.
|
||||
// Without this, `ctrl+enter` does the wrong thing.
|
||||
@@ -1436,9 +1466,13 @@ extension Ghostty {
|
||||
item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise")
|
||||
item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "")
|
||||
item.setImageIfDesired(systemSymbolName: "scope")
|
||||
item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "")
|
||||
item.setImageIfDesired(systemSymbolName: "eye.fill")
|
||||
item.state = readonly ? .on : .off
|
||||
menu.addItem(.separator())
|
||||
item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
|
||||
item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
|
||||
item.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -1485,7 +1519,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func find(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "start_search"
|
||||
@@ -1493,7 +1527,23 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func selectionForFind(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "search_selection"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func scrollToSelection(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "scroll_to_selection"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func findNext(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "search:next"
|
||||
@@ -1509,7 +1559,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func findHide(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "end_search"
|
||||
@@ -1518,6 +1568,22 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func toggleReadonly(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "toggle_readonly"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers a brief highlight animation on this surface.
|
||||
func highlight() {
|
||||
highlighted = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||||
self?.highlighted = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func splitRight(_ sender: Any) {
|
||||
guard let surface = self.surface else { return }
|
||||
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
|
||||
@@ -1553,7 +1619,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func changeTitle(_ sender: Any) {
|
||||
promptTitle()
|
||||
}
|
||||
@@ -1614,6 +1680,7 @@ extension Ghostty {
|
||||
struct DerivedConfig {
|
||||
let backgroundColor: Color
|
||||
let backgroundOpacity: Double
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
let macosWindowShadow: Bool
|
||||
let windowTitleFontFamily: String?
|
||||
let windowAppearance: NSAppearance?
|
||||
@@ -1622,6 +1689,7 @@ extension Ghostty {
|
||||
init() {
|
||||
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
||||
self.backgroundOpacity = 1
|
||||
self.backgroundBlur = .disabled
|
||||
self.macosWindowShadow = true
|
||||
self.windowTitleFontFamily = nil
|
||||
self.windowAppearance = nil
|
||||
@@ -1631,6 +1699,7 @@ extension Ghostty {
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.backgroundColor = config.backgroundColor
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.macosWindowShadow = config.macosWindowShadow
|
||||
self.windowTitleFontFamily = config.windowTitleFontFamily
|
||||
self.windowAppearance = .init(ghosttyConfig: config)
|
||||
@@ -1663,7 +1732,7 @@ extension Ghostty {
|
||||
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
|
||||
|
||||
self.init(app, baseConfig: config, uuid: uuid)
|
||||
|
||||
|
||||
// Restore the saved title after initialization
|
||||
if let title = savedTitle {
|
||||
self.title = title
|
||||
@@ -1880,6 +1949,17 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
return
|
||||
}
|
||||
|
||||
guard let surfaceModel else { return }
|
||||
// Process MacOS native scroll events
|
||||
switch selector {
|
||||
case #selector(moveToBeginningOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_top")
|
||||
case #selector(moveToEndOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_bottom")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
print("SEL: \(selector)")
|
||||
}
|
||||
|
||||
@@ -1920,14 +2000,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
|
||||
// but get requested an image (we can't handle that at the time of writing this),
|
||||
// so we must bubble up.
|
||||
|
||||
|
||||
// Types we can receive
|
||||
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
|
||||
|
||||
|
||||
// Types that we can send. Currently the same as receivable but I'm separating
|
||||
// this out so we can modify this in the future.
|
||||
let sendable: [NSPasteboard.PasteboardType] = receivable
|
||||
|
||||
|
||||
// The sendable types that require a selection (currently all)
|
||||
let sendableRequiresSelection = sendable
|
||||
|
||||
@@ -1944,7 +2024,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
return super.validRequestor(forSendType: sendType, returnType: returnType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -1990,10 +2070,14 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
|
||||
let pb = NSPasteboard.ghosttySelection
|
||||
guard let str = pb.getOpinionatedStringContents() else { return false }
|
||||
return !str.isEmpty
|
||||
|
||||
|
||||
case #selector(findHide):
|
||||
return searchState != nil
|
||||
|
||||
case #selector(toggleReadonly):
|
||||
item.state = readonly ? .on : .off
|
||||
return true
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -2091,7 +2175,7 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilitySelectedTextRange() -> NSRange {
|
||||
return selectedRange()
|
||||
}
|
||||
|
||||
|
||||
/// Returns the currently selected text as a string.
|
||||
/// This allows assistive technologies to read the selected content.
|
||||
override func accessibilitySelectedText() -> String? {
|
||||
@@ -2105,21 +2189,21 @@ extension Ghostty.SurfaceView {
|
||||
let str = String(cString: text.text)
|
||||
return str.isEmpty ? nil : str
|
||||
}
|
||||
|
||||
|
||||
/// Returns the number of characters in the terminal content.
|
||||
/// This helps assistive technologies understand the size of the content.
|
||||
override func accessibilityNumberOfCharacters() -> Int {
|
||||
let content = cachedScreenContents.get()
|
||||
return content.count
|
||||
}
|
||||
|
||||
|
||||
/// Returns the visible character range for the terminal.
|
||||
/// For terminals, we typically show all content as visible.
|
||||
override func accessibilityVisibleCharacterRange() -> NSRange {
|
||||
let content = cachedScreenContents.get()
|
||||
return NSRange(location: 0, length: content.count)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the line number for a given character index.
|
||||
/// This helps assistive technologies navigate by line.
|
||||
override func accessibilityLine(for index: Int) -> Int {
|
||||
@@ -2127,7 +2211,7 @@ extension Ghostty.SurfaceView {
|
||||
let substring = String(content.prefix(index))
|
||||
return substring.components(separatedBy: .newlines).count - 1
|
||||
}
|
||||
|
||||
|
||||
/// Returns a substring for the given range.
|
||||
/// This allows assistive technologies to read specific portions of the content.
|
||||
override func accessibilityString(for range: NSRange) -> String? {
|
||||
@@ -2135,7 +2219,7 @@ extension Ghostty.SurfaceView {
|
||||
guard let swiftRange = Range(range, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
|
||||
|
||||
/// Returns an attributed string for the given range.
|
||||
///
|
||||
/// Note: right now this only applies font information. One day it'd be nice to extend
|
||||
@@ -2146,9 +2230,9 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
|
||||
guard let surface = self.surface else { return nil }
|
||||
guard let plainString = accessibilityString(for: range) else { return nil }
|
||||
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
|
||||
|
||||
// Try to get the font from the surface
|
||||
if let fontRaw = ghostty_surface_quicklook_font(surface) {
|
||||
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
|
||||
@@ -2158,6 +2242,7 @@ extension Ghostty.SurfaceView {
|
||||
|
||||
return NSAttributedString(string: plainString, attributes: attributes)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Caches a value for some period of time, evicting it automatically when that time expires.
|
||||
@@ -4,8 +4,10 @@ import GhosttyKit
|
||||
extension Ghostty {
|
||||
/// The UIView implementation for a terminal surface.
|
||||
class SurfaceView: UIView, ObservableObject {
|
||||
typealias ID = UUID
|
||||
|
||||
/// Unique ID per surface
|
||||
let uuid: UUID
|
||||
let id: UUID
|
||||
|
||||
// The current title of the surface as defined by the pty. This can be
|
||||
// changed with escape codes. This is public because the callbacks go
|
||||
@@ -44,6 +46,15 @@ extension Ghostty {
|
||||
// The current search state. When non-nil, the search overlay should be shown.
|
||||
@Published var searchState: SearchState? = nil
|
||||
|
||||
// The currently active key tables. Empty if no tables are active.
|
||||
@Published var keyTables: [String] = []
|
||||
|
||||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
|
||||
@Published private(set) var highlighted: Bool = false
|
||||
|
||||
// Returns sizing information for the surface. This is the raw C
|
||||
// structure because I'm lazy.
|
||||
var surfaceSize: ghostty_surface_size_s? {
|
||||
@@ -54,7 +65,7 @@ extension Ghostty {
|
||||
private(set) var surface: ghostty_surface_t?
|
||||
|
||||
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
|
||||
self.uuid = uuid ?? .init()
|
||||
self.id = uuid ?? .init()
|
||||
|
||||
// Initialize with some default frame size. The important thing is that this
|
||||
// is non-zero so that our layer bounds are non-zero so that our renderer
|
||||
25
macos/Sources/Helpers/AnySortKey.swift
Normal file
25
macos/Sources/Helpers/AnySortKey.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// Type-erased wrapper for any Comparable type to use as a sort key.
|
||||
struct AnySortKey: Comparable {
|
||||
private let value: Any
|
||||
private let comparator: (Any, Any) -> ComparisonResult
|
||||
|
||||
init<T: Comparable>(_ value: T) {
|
||||
self.value = value
|
||||
self.comparator = { lhs, rhs in
|
||||
guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame }
|
||||
if l < r { return .orderedAscending }
|
||||
if l > r { return .orderedDescending }
|
||||
return .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
|
||||
lhs.comparator(lhs.value, rhs.value) == .orderedAscending
|
||||
}
|
||||
|
||||
static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
|
||||
lhs.comparator(lhs.value, rhs.value) == .orderedSame
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
struct DraggableWindowView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> DraggableWindowNSView {
|
||||
return DraggableWindowNSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DraggableWindowNSView, context: Context) {
|
||||
// No need to update anything here
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableWindowNSView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window = self.window else { return }
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
}
|
||||
39
macos/Sources/Helpers/Extensions/NSColor+Extension.swift
Normal file
39
macos/Sources/Helpers/Extensions/NSColor+Extension.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import AppKit
|
||||
|
||||
extension NSColor {
|
||||
/// Using a color list let's us get localized names.
|
||||
private static let appleColorList: NSColorList? = NSColorList(named: "Apple")
|
||||
|
||||
convenience init?(named name: String) {
|
||||
guard let colorList = Self.appleColorList,
|
||||
let color = colorList.color(withKey: name.capitalized) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = color.usingColorSpace(.sRGB) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
red: components.redComponent,
|
||||
green: components.greenComponent,
|
||||
blue: components.blueComponent,
|
||||
alpha: components.alphaComponent
|
||||
)
|
||||
}
|
||||
|
||||
static var colorNames: [String] {
|
||||
appleColorList?.allKeys.map { $0.lowercased() } ?? []
|
||||
}
|
||||
|
||||
/// Calculates the perceptual distance to another color in RGB space.
|
||||
func distance(to other: NSColor) -> Double {
|
||||
guard let a = self.usingColorSpace(.sRGB),
|
||||
let b = other.usingColorSpace(.sRGB) else { return .infinity }
|
||||
|
||||
let dr = a.redComponent - b.redComponent
|
||||
let dg = a.greenComponent - b.greenComponent
|
||||
let db = a.blueComponent - b.blueComponent
|
||||
|
||||
// Weighted Euclidean distance (human eye is more sensitive to green)
|
||||
return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db)
|
||||
}
|
||||
}
|
||||
42
macos/Sources/Helpers/Extensions/NSMenu+Extension.swift
Normal file
42
macos/Sources/Helpers/Extensions/NSMenu+Extension.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import AppKit
|
||||
|
||||
extension NSMenu {
|
||||
/// Inserts a menu item after an existing item with the specified action selector.
|
||||
///
|
||||
/// If an item with the same identifier already exists, it is removed first to avoid duplicates.
|
||||
/// This is useful when menus are cached and reused across different targets.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - item: The menu item to insert.
|
||||
/// - action: The action selector to search for. The new item will be inserted after the first
|
||||
/// item with this action.
|
||||
/// - Returns: The index where the item was inserted, or `nil` if the action was not found
|
||||
/// and the item was not inserted.
|
||||
@discardableResult
|
||||
func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? {
|
||||
if let identifier = item.identifier,
|
||||
let existing = items.first(where: { $0.identifier == identifier }) {
|
||||
removeItem(existing)
|
||||
}
|
||||
|
||||
guard let idx = items.firstIndex(where: { $0.action == action }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let insertionIndex = idx + 1
|
||||
insertItem(item, at: insertionIndex)
|
||||
return UInt(insertionIndex)
|
||||
}
|
||||
|
||||
/// Removes all menu items whose identifier is in the given set.
|
||||
///
|
||||
/// - Parameter identifiers: The set of identifiers to match for removal.
|
||||
func removeItems(withIdentifiers identifiers: Set<NSUserInterfaceItemIdentifier>) {
|
||||
for (index, item) in items.enumerated().reversed() {
|
||||
guard let identifier = item.identifier else { continue }
|
||||
if identifiers.contains(identifier) {
|
||||
removeItem(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,73 @@ extension NSWindow {
|
||||
return CGWindowID(windowNumber)
|
||||
}
|
||||
|
||||
/// True if this is the first window in the tab group.
|
||||
var isFirstWindowInTabGroup: Bool {
|
||||
guard let firstWindow = tabGroup?.windows.first else { return true }
|
||||
return firstWindow === self
|
||||
}
|
||||
|
||||
/// Adjusts the window origin if necessary to ensure the window remains visible on screen.
|
||||
/// Adjusts the window frame if necessary to ensure the window remains visible on screen.
|
||||
/// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen).
|
||||
func constrainToScreen() {
|
||||
guard let screen = screen ?? NSScreen.main else { return }
|
||||
let visibleFrame = screen.visibleFrame
|
||||
var windowFrame = frame
|
||||
|
||||
windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width)
|
||||
windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height)
|
||||
|
||||
windowFrame.origin.x = max(visibleFrame.minX,
|
||||
min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width))
|
||||
windowFrame.origin.y = max(visibleFrame.minY,
|
||||
min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height))
|
||||
|
||||
if windowFrame.origin != frame.origin {
|
||||
setFrameOrigin(windowFrame.origin)
|
||||
if windowFrame != frame {
|
||||
setFrame(windowFrame, display: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Native Tabbing
|
||||
|
||||
extension NSWindow {
|
||||
/// True if this is the first window in the tab group.
|
||||
var isFirstWindowInTabGroup: Bool {
|
||||
guard let firstWindow = tabGroup?.windows.first else { return true }
|
||||
return firstWindow === self
|
||||
}
|
||||
}
|
||||
|
||||
/// Native tabbing private API usage. :(
|
||||
extension NSWindow {
|
||||
var titlebarView: NSView? {
|
||||
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||
guard let themeFrameView = contentView?.rootView else { return nil }
|
||||
guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil }
|
||||
return themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||
}
|
||||
|
||||
/// Returns the [private] NSTabBar view, if it exists.
|
||||
var tabBarView: NSView? {
|
||||
titlebarView?.firstDescendant(withClassName: "NSTabBar")
|
||||
}
|
||||
|
||||
/// Returns the index of the tab button at the given screen point, if any.
|
||||
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
|
||||
guard let tabBarView else { return nil }
|
||||
let locationInWindow = convertPoint(fromScreen: screenPoint)
|
||||
let locationInTabBar = tabBarView.convert(locationInWindow, from: nil)
|
||||
guard tabBarView.bounds.contains(locationInTabBar) else { return nil }
|
||||
|
||||
// Find all tab buttons and sort by x position to get visual order.
|
||||
// The view hierarchy order doesn't match the visual tab order.
|
||||
let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton")
|
||||
.sorted { $0.frame.origin.x < $1.frame.origin.x }
|
||||
|
||||
for (index, tabItemView) in tabItemViews.enumerated() {
|
||||
let locationInTab = tabItemView.convert(locationInWindow, from: nil)
|
||||
if tabItemView.bounds.contains(locationInTab) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ extension String {
|
||||
return self.prefix(maxLength) + trailing
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
#if canImport(AppKit)
|
||||
func temporaryFile(_ filename: String = "temp") -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(filename)
|
||||
@@ -16,5 +16,14 @@ extension String {
|
||||
try? string.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns the path with the home directory abbreviated as ~.
|
||||
var abbreviatedPath: String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
if hasPrefix(home) {
|
||||
return "~" + dropFirst(home.count)
|
||||
}
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import AppKit
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension Transferable {
|
||||
/// Converts this Transferable to an NSPasteboardItem with lazy data loading.
|
||||
/// Data is only fetched when the pasteboard consumer requests it. This allows
|
||||
/// bridging a Transferable to NSDraggingSource.
|
||||
func pasteboardItem() -> NSPasteboardItem? {
|
||||
let itemProvider = NSItemProvider()
|
||||
itemProvider.register(self)
|
||||
|
||||
let types = itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) }
|
||||
guard !types.isEmpty else { return nil }
|
||||
|
||||
let item = NSPasteboardItem()
|
||||
let dataProvider = TransferableDataProvider(itemProvider: itemProvider)
|
||||
let pasteboardTypes = types.map { NSPasteboard.PasteboardType($0.identifier) }
|
||||
item.setDataProvider(dataProvider, forTypes: pasteboardTypes)
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProvider {
|
||||
private let itemProvider: NSItemProvider
|
||||
|
||||
init(itemProvider: NSItemProvider) {
|
||||
self.itemProvider = itemProvider
|
||||
super.init()
|
||||
}
|
||||
|
||||
func pasteboard(
|
||||
_ pasteboard: NSPasteboard?,
|
||||
item: NSPasteboardItem,
|
||||
provideDataForType type: NSPasteboard.PasteboardType
|
||||
) {
|
||||
// NSPasteboardItemDataProvider requires synchronous data return, but
|
||||
// NSItemProvider.loadDataRepresentation is async. We use a semaphore
|
||||
// to block until the async load completes. This is safe because AppKit
|
||||
// calls this method on a background thread during drag operations.
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
var result: Data?
|
||||
itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in
|
||||
result = data
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
// Wait for the data to load
|
||||
semaphore.wait()
|
||||
|
||||
// Set it. I honestly don't know what happens here if this fails.
|
||||
if let data = result {
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
|
||||
class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
var fullscreenMode: FullscreenMode { .nonNative }
|
||||
|
||||
|
||||
// Non-native fullscreen never supports tabs because tabs require
|
||||
// the "titled" style and we don't have it for non-native fullscreen.
|
||||
var supportsTabs: Bool { false }
|
||||
@@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
// Being untitled let's our content take up the full frame.
|
||||
window.styleMask.remove(.titled)
|
||||
|
||||
// We dont' want the non-native fullscreen window to be resizable
|
||||
// We don't want the non-native fullscreen window to be resizable
|
||||
// from the edges.
|
||||
window.styleMask.remove(.resizable)
|
||||
|
||||
@@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
if let window = window as? TerminalWindow, window.isTabBar(c) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
|
||||
window.addTitlebarAccessoryViewController(c)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
// Removing "titled" also clears our toolbar
|
||||
window.toolbar = savedState.toolbar
|
||||
window.toolbarStyle = savedState.toolbarStyle
|
||||
|
||||
|
||||
// If the window was previously in a tab group that isn't empty now,
|
||||
// we re-add it. We have to do this because our process of doing non-native
|
||||
// fullscreen removes the window from the tab group.
|
||||
@@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
self.toolbar = window.toolbar
|
||||
self.toolbarStyle = window.toolbarStyle
|
||||
self.dock = window.screen?.hasDock ?? false
|
||||
|
||||
|
||||
self.titlebarAccessoryViewControllers = if (window.hasTitleBar) {
|
||||
// Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash.
|
||||
window.titlebarAccessoryViewControllers
|
||||
|
||||
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Testing
|
||||
import AppKit
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
@testable import Ghostty
|
||||
|
||||
struct TransferablePasteboardTests {
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// A simple Transferable type for testing pasteboard conversion.
|
||||
private struct DummyTransferable: Transferable, Equatable {
|
||||
let payload: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.payload.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
let string = String(data: data, encoding: .utf8)!
|
||||
return DummyTransferable(payload: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transferable type that registers multiple content types.
|
||||
private struct MultiTypeTransferable: Transferable {
|
||||
let text: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
DataRepresentation(contentType: .plainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Basic Functionality
|
||||
|
||||
@Test func pasteboardItemIsCreated() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
let item = transferable.pasteboardItem()
|
||||
#expect(item != nil)
|
||||
}
|
||||
|
||||
@Test func pasteboardItemContainsExpectedType() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
#expect(item.types.contains(expectedType))
|
||||
}
|
||||
|
||||
@Test func pasteboardItemProvidesCorrectData() {
|
||||
let transferable = DummyTransferable(payload: "test data")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
|
||||
// Write to a pasteboard to trigger data provider
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Read back the data
|
||||
guard let data = pasteboard.data(forType: pasteboardType) else {
|
||||
Issue.record("Expected data to be available on pasteboard")
|
||||
return
|
||||
}
|
||||
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
#expect(string == "test data")
|
||||
}
|
||||
|
||||
// MARK: - Multiple Content Types
|
||||
|
||||
@Test func multipleTypesAreRegistered() {
|
||||
let transferable = MultiTypeTransferable(text: "multi")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
#expect(item.types.contains(utf8Type))
|
||||
#expect(item.types.contains(plainType))
|
||||
}
|
||||
|
||||
@Test func multipleTypesProvideCorrectData() {
|
||||
let transferable = MultiTypeTransferable(text: "shared content")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Both types should provide the same content
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
if let utf8Data = pasteboard.data(forType: utf8Type) {
|
||||
#expect(String(data: utf8Data, encoding: .utf8) == "shared content")
|
||||
}
|
||||
|
||||
if let plainData = pasteboard.data(forType: plainType) {
|
||||
#expect(String(data: plainData, encoding: .utf8) == "shared content")
|
||||
}
|
||||
}
|
||||
}
|
||||
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Ghostty
|
||||
|
||||
struct TerminalSplitDropZoneTests {
|
||||
private let standardSize = CGSize(width: 100, height: 100)
|
||||
|
||||
// MARK: - Basic Edge Detection
|
||||
|
||||
@Test func topEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func bottomEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
@Test func leftEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func rightEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Corner Tie-Breaking
|
||||
// When distances are equal, the check order determines the result:
|
||||
// left -> right -> top -> bottom
|
||||
|
||||
@Test func topLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func topRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func bottomLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func bottomRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Center Point (All Distances Equal)
|
||||
|
||||
@Test func centerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
// MARK: - Non-Square Aspect Ratio
|
||||
|
||||
@Test func rectangularViewTopEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func rectangularViewLeftEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func tallRectangleTopEdge() {
|
||||
let size = CGSize(width: 100, height: 200)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
// MARK: - Out-of-Bounds Points
|
||||
|
||||
@Test func pointLeftOfViewSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func pointAboveViewSelectsTop() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func pointRightOfViewSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func pointBelowViewSelectsBottom() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Diagonal Regions (Triangular Zones)
|
||||
|
||||
@Test func upperLeftTriangleSelectsLeft() {
|
||||
// Point in the upper-left triangle, closer to left than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func upperRightTriangleSelectsRight() {
|
||||
// Point in the upper-right triangle, closer to right than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func lowerLeftTriangleSelectsLeft() {
|
||||
// Point in the lower-left triangle, closer to left than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func lowerRightTriangleSelectsRight() {
|
||||
// Point in the lower-right triangle, closer to right than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
wasmtime,
|
||||
wraptest,
|
||||
zig,
|
||||
zig_0_15,
|
||||
zip,
|
||||
llvmPackages_latest,
|
||||
bzip2,
|
||||
@@ -70,7 +71,6 @@
|
||||
wayland-scanner,
|
||||
wayland-protocols,
|
||||
zon2nix,
|
||||
system,
|
||||
pkgs,
|
||||
# needed by GTK for loading SVG icons while running from within the
|
||||
# developer shell
|
||||
@@ -100,7 +100,7 @@ in
|
||||
scdoc
|
||||
zig
|
||||
zip
|
||||
zon2nix.packages.${system}.zon2nix
|
||||
zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix
|
||||
|
||||
# For web and wasm stuff
|
||||
nodejs
|
||||
|
||||
@@ -20,16 +20,6 @@
|
||||
wayland-scanner,
|
||||
pkgs,
|
||||
}: let
|
||||
# The Zig hook has no way to select the release type without actual
|
||||
# overriding of the default flags.
|
||||
#
|
||||
# TODO: Once
|
||||
# https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is
|
||||
# ultimately acted on and has made its way to a nixpkgs implementation, this
|
||||
# can probably be removed in favor of that.
|
||||
zig_hook = zig_0_15.hook.overrideAttrs {
|
||||
zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off";
|
||||
};
|
||||
gi_typelib_path = import ./build-support/gi-typelib-path.nix {
|
||||
inherit pkgs lib stdenv;
|
||||
};
|
||||
@@ -73,7 +63,7 @@ in
|
||||
ncurses
|
||||
pandoc
|
||||
pkg-config
|
||||
zig_hook
|
||||
zig_0_15
|
||||
gobject-introspection
|
||||
wrapGAppsHook4
|
||||
blueprint-compiler
|
||||
@@ -92,12 +82,16 @@ in
|
||||
|
||||
GI_TYPELIB_PATH = gi_typelib_path;
|
||||
|
||||
dontSetZigDefaultFlags = true;
|
||||
|
||||
zigBuildFlags = [
|
||||
"--system"
|
||||
"${finalAttrs.deps}"
|
||||
"-Dversion-string=${finalAttrs.version}-${revision}-nix"
|
||||
"-Dgtk-x11=${lib.boolToString enableX11}"
|
||||
"-Dgtk-wayland=${lib.boolToString enableWayland}"
|
||||
"-Dcpu=baseline"
|
||||
"-Doptimize=${optimize}"
|
||||
"-Dstrip=${lib.boolToString strip}"
|
||||
];
|
||||
|
||||
|
||||
283
nix/tests.nix
Normal file
283
nix/tests.nix
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
self,
|
||||
system,
|
||||
nixpkgs,
|
||||
home-manager,
|
||||
...
|
||||
}: let
|
||||
nixos-version = nixpkgs.lib.trivial.release;
|
||||
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [
|
||||
self.overlays.debug
|
||||
];
|
||||
};
|
||||
|
||||
pink_value = "#FF0087";
|
||||
|
||||
color_test = ''
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
def check_for_pink(final=False) -> bool:
|
||||
with tempfile.NamedTemporaryFile() as tmpin:
|
||||
machine.send_monitor_command("screendump {}".format(tmpin.name))
|
||||
|
||||
cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format(
|
||||
tmpin.name
|
||||
)
|
||||
ret = subprocess.run(cmd, shell=True, capture_output=True)
|
||||
if ret.returncode != 0:
|
||||
raise Exception(
|
||||
"image analysis failed with exit code {}".format(ret.returncode)
|
||||
)
|
||||
|
||||
text = ret.stdout.decode("utf-8")
|
||||
return "${pink_value}" in text
|
||||
'';
|
||||
|
||||
mkNodeGnome = {
|
||||
config,
|
||||
pkgs,
|
||||
settings,
|
||||
sshPort ? null,
|
||||
...
|
||||
}: {
|
||||
imports = [
|
||||
./vm/wayland-gnome.nix
|
||||
settings
|
||||
];
|
||||
|
||||
virtualisation = {
|
||||
forwardPorts = pkgs.lib.optionals (sshPort != null) [
|
||||
{
|
||||
from = "host";
|
||||
host.port = sshPort;
|
||||
guest.port = 22;
|
||||
}
|
||||
];
|
||||
|
||||
vmVariant = {
|
||||
virtualisation.host.pkgs = pkgs;
|
||||
};
|
||||
};
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PermitRootLogin = "yes";
|
||||
PermitEmptyPasswords = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
security.pam.services.sshd.allowNullPassword = true;
|
||||
|
||||
users.groups.ghostty = {
|
||||
gid = 1000;
|
||||
};
|
||||
|
||||
users.users.ghostty = {
|
||||
uid = 1000;
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
users = {
|
||||
ghostty = {
|
||||
home = {
|
||||
username = config.users.users.ghostty.name;
|
||||
homeDirectory = config.users.users.ghostty.home;
|
||||
stateVersion = nixos-version;
|
||||
};
|
||||
programs.ssh = {
|
||||
enable = true;
|
||||
extraOptionOverrides = {
|
||||
StrictHostKeyChecking = "accept-new";
|
||||
UserKnownHostsFile = "/dev/null";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = nixos-version;
|
||||
};
|
||||
|
||||
mkTestGnome = {
|
||||
name,
|
||||
settings,
|
||||
testScript,
|
||||
ocr ? false,
|
||||
}:
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = name;
|
||||
|
||||
enableOCR = ocr;
|
||||
|
||||
extraBaseModules = {
|
||||
imports = [
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
};
|
||||
|
||||
nodes = {
|
||||
machine = {
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
mkNodeGnome {
|
||||
inherit config pkgs settings;
|
||||
sshPort = 2222;
|
||||
};
|
||||
};
|
||||
|
||||
testScript = testScript;
|
||||
};
|
||||
in {
|
||||
basic-version-check = pkgs.testers.runNixOSTest {
|
||||
name = "basic-version-check";
|
||||
nodes = {
|
||||
machine = {pkgs, ...}: {
|
||||
users.groups.ghostty = {};
|
||||
users.users.ghostty = {
|
||||
isNormalUser = true;
|
||||
group = "ghostty";
|
||||
extraGroups = ["wheel"];
|
||||
hashedPassword = "";
|
||||
packages = [
|
||||
pkgs.ghostty
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
testScript = {...}: ''
|
||||
machine.succeed("su - ghostty -c 'ghostty +version'")
|
||||
'';
|
||||
};
|
||||
|
||||
basic-window-check-gnome = mkTestGnome {
|
||||
name = "basic-window-check-gnome";
|
||||
settings = {
|
||||
home-manager.users.ghostty = {
|
||||
xdg.configFile = {
|
||||
"ghostty/config".text = ''
|
||||
background = ${pink_value}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
ocr = true;
|
||||
testScript = {nodes, ...}: let
|
||||
user = nodes.machine.users.users.ghostty;
|
||||
bus_path = "/run/user/${toString user.uid}/bus";
|
||||
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
|
||||
gdbus = "${bus} gdbus";
|
||||
ghostty = "${bus} ghostty";
|
||||
su = command: "su - ${user.name} -c '${command}'";
|
||||
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
|
||||
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
|
||||
in ''
|
||||
${color_test}
|
||||
|
||||
with subtest("wait for x"):
|
||||
start_all()
|
||||
machine.wait_for_x()
|
||||
|
||||
machine.wait_for_file("${bus_path}")
|
||||
|
||||
with subtest("Ensuring no pink is present without the terminal."):
|
||||
assert (
|
||||
check_for_pink() == False
|
||||
), "Pink was present on the screen before we even launched a terminal!"
|
||||
|
||||
machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
|
||||
machine.succeed("${su "${ghostty} +new-window"}")
|
||||
machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
|
||||
|
||||
machine.sleep(2)
|
||||
|
||||
with subtest("Have the terminal display a color."):
|
||||
assert(
|
||||
check_for_pink() == True
|
||||
), "Pink was not found on the screen!"
|
||||
|
||||
machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}")
|
||||
'';
|
||||
};
|
||||
|
||||
ssh-integration-test = pkgs.testers.runNixOSTest {
|
||||
name = "ssh-integration-test";
|
||||
extraBaseModules = {
|
||||
imports = [
|
||||
home-manager.nixosModules.home-manager
|
||||
];
|
||||
};
|
||||
nodes = {
|
||||
server = {...}: {
|
||||
users.groups.ghostty = {};
|
||||
users.users.ghostty = {
|
||||
isNormalUser = true;
|
||||
group = "ghostty";
|
||||
extraGroups = ["wheel"];
|
||||
hashedPassword = "";
|
||||
packages = [];
|
||||
};
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PermitRootLogin = "yes";
|
||||
PermitEmptyPasswords = "yes";
|
||||
};
|
||||
};
|
||||
security.pam.services.sshd.allowNullPassword = true;
|
||||
};
|
||||
client = {
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
mkNodeGnome {
|
||||
inherit config pkgs;
|
||||
settings = {
|
||||
home-manager.users.ghostty = {
|
||||
xdg.configFile = {
|
||||
"ghostty/config".text = let
|
||||
in ''
|
||||
shell-integration-features = ssh-terminfo
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
sshPort = 2222;
|
||||
};
|
||||
};
|
||||
testScript = {nodes, ...}: let
|
||||
user = nodes.client.users.users.ghostty;
|
||||
bus_path = "/run/user/${toString user.uid}/bus";
|
||||
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
|
||||
gdbus = "${bus} gdbus";
|
||||
ghostty = "${bus} ghostty";
|
||||
su = command: "su - ${user.name} -c '${command}'";
|
||||
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
|
||||
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
|
||||
in ''
|
||||
with subtest("Start server and wait for ssh to be ready."):
|
||||
server.start()
|
||||
server.wait_for_open_port(22)
|
||||
|
||||
with subtest("Start client and wait for ghostty window."):
|
||||
client.start()
|
||||
client.wait_for_x()
|
||||
client.wait_for_file("${bus_path}")
|
||||
client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
|
||||
client.succeed("${su "${ghostty} +new-window"}")
|
||||
client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
|
||||
|
||||
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied."):
|
||||
client.sleep(2)
|
||||
client.send_chars("ssh ghostty@server\n")
|
||||
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
./common.nix
|
||||
];
|
||||
|
||||
services.xserver = {
|
||||
services = {
|
||||
displayManager = {
|
||||
gdm = {
|
||||
enable = true;
|
||||
@@ -22,6 +22,19 @@
|
||||
};
|
||||
};
|
||||
|
||||
systemd.user.services = {
|
||||
"org.gnome.Shell@wayland" = {
|
||||
serviceConfig = {
|
||||
ExecStart = [
|
||||
# Clear the list before overriding it.
|
||||
""
|
||||
# Eval API is now internal so Shell needs to run in unsafe mode.
|
||||
"${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.gnomeExtensions.no-overview
|
||||
];
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
documentation.nixos.enable = false;
|
||||
|
||||
networking.hostName = "ghostty";
|
||||
networking.domain = "mitchellh.com";
|
||||
|
||||
virtualisation.vmVariant = {
|
||||
virtualisation.memorySize = 2048;
|
||||
};
|
||||
@@ -28,17 +25,11 @@
|
||||
users.groups.ghostty = {};
|
||||
|
||||
users.users.ghostty = {
|
||||
isNormalUser = true;
|
||||
description = "Ghostty";
|
||||
group = "ghostty";
|
||||
extraGroups = ["wheel"];
|
||||
isNormalUser = true;
|
||||
initialPassword = "ghostty";
|
||||
};
|
||||
|
||||
environment.etc = {
|
||||
"xdg/autostart/com.mitchellh.ghostty.desktop" = {
|
||||
source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop";
|
||||
};
|
||||
hashedPassword = "";
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
@@ -61,6 +52,7 @@
|
||||
|
||||
services.displayManager = {
|
||||
autoLogin = {
|
||||
enable = true;
|
||||
user = "ghostty";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{...}: {
|
||||
imports = [
|
||||
./common-gnome.nix
|
||||
];
|
||||
|
||||
services.displayManager = {
|
||||
defaultSession = "gnome-xorg";
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
const std = @import("std");
|
||||
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const module = b.addModule("cimgui", .{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const imgui_ = b.lazyDependency("imgui", .{});
|
||||
const lib = b.addLibrary(.{
|
||||
.name = "cimgui",
|
||||
.root_module = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
.linkage = .static,
|
||||
});
|
||||
lib.linkLibC();
|
||||
lib.linkLibCpp();
|
||||
if (target.result.os.tag == .windows) {
|
||||
lib.linkSystemLibrary("imm32");
|
||||
}
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
|
||||
if (b.systemIntegrationOption("freetype", .{})) {
|
||||
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
|
||||
} else {
|
||||
const freetype = b.dependency("freetype", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
lib.linkLibrary(freetype.artifact("freetype"));
|
||||
|
||||
if (freetype.builder.lazyDependency(
|
||||
"freetype",
|
||||
.{},
|
||||
)) |freetype_dep| {
|
||||
module.addIncludePath(freetype_dep.path("include"));
|
||||
}
|
||||
}
|
||||
|
||||
if (imgui_) |imgui| lib.addIncludePath(imgui.path(""));
|
||||
module.addIncludePath(b.path("vendor"));
|
||||
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DCIMGUI_FREETYPE=1",
|
||||
"-DIMGUI_USE_WCHAR32=1",
|
||||
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
|
||||
});
|
||||
if (target.result.os.tag == .windows) {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
|
||||
});
|
||||
} else {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"",
|
||||
});
|
||||
}
|
||||
|
||||
if (imgui_) |imgui| {
|
||||
lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_draw.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_demo.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_widgets.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_tables.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("misc/freetype/imgui_freetype.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_opengl3.cpp"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
if (target.result.os.tag.isDarwin()) {
|
||||
if (!target.query.isNative()) {
|
||||
try @import("apple_sdk").addPaths(b, lib);
|
||||
}
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_metal.mm"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
if (target.result.os.tag == .macos) {
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_osx.mm"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
b.path("vendor"),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
|
||||
b.installArtifact(lib);
|
||||
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_exe.linkLibrary(lib);
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.{
|
||||
.name = .cimgui,
|
||||
.version = "1.90.6", // -docking branch
|
||||
.fingerprint = 0x49726f5f8acbc90d,
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// This should be kept in sync with the submodule in the cimgui source
|
||||
// code in ./vendor/ to be safe that they're compatible.
|
||||
.imgui = .{
|
||||
// ocornut/imgui
|
||||
.url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
.hash = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
.freetype = .{ .path = "../freetype" },
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub const c = @cImport({
|
||||
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
|
||||
@cInclude("cimgui.h");
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
pub const c = @import("c.zig").c;
|
||||
|
||||
// OpenGL
|
||||
pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.c) void;
|
||||
|
||||
// Metal
|
||||
pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.c) void;
|
||||
|
||||
// OSX
|
||||
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
|
||||
|
||||
test {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user