Merge remote-tracking branch 'origin/main' into shaping-positions

pull/9883/head
Jacob Sandlund 2025-12-17 09:18:10 -05:00
commit e28e4facf0
81 changed files with 3021 additions and 405 deletions

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

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

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

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

View File

@ -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: |-
@ -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
@ -309,7 +309,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: macos
@ -340,7 +340,7 @@ jobs:
mv appcast_new.xml appcast.xml
- name: Upload artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sparkle
path: |-
@ -357,17 +357,17 @@ jobs:
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
steps:
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: macos
- name: Download Sparkle Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: sparkle
- name: Download Source Tarball Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: source-tarball

View File

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

View File

@ -24,7 +24,7 @@ jobs:
- build-linux-libghostty
- build-nix
- build-macos
- build-macos-matrix
- build-macos-freetype
- build-snap
- build-windows
- test
@ -44,7 +44,7 @@ jobs:
- test-debian-13
- valgrind
- zig-fmt
- flatpak
steps:
- id: status
name: Determine status
@ -397,7 +397,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: |-
@ -421,6 +421,24 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- 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
@ -464,7 +482,7 @@ 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:
@ -493,18 +511,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
@ -1075,7 +1085,7 @@ jobs:
uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20
- 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 +1102,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

View File

@ -37,16 +37,33 @@ jobs:
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: |
@ -75,5 +92,5 @@ jobs:
build.zig.zon.json
flatpak/zig-packages.json
body: |
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }}
Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }}
labels: dependencies

1
.gitignore vendored
View File

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

View File

@ -17,14 +17,12 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
> [!IMPORTANT]
>
> If you are using **any kind of AI assistance** to contribute to Ghostty,
> it must be disclosed in the pull request.
> The Ghostty project allows AI-**assisted** _code contributions_, which
> must be properly 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
@ -32,10 +30,49 @@ 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. ❤️
> [!WARNING]
> **Note that AI _assistance_ does not equal AI _generation_**. We require
> a significant amount of human accountability, involvement and interaction
> even within AI-assisted contributions. Contributors are required to be able
> to understand the AI-assisted output, reason with it and answer critical
> questions about it. Should a PR see no visible human accountability and
> involvement, or it is so broken that it requires significant rework to be
> acceptable, **we reserve the right to close it without hesitation**.
**In addition, we currently restrict AI assistance to code changes only.**
No AI-generated media, e.g. artwork, icons, videos and other assets is
allowed, as it goes against the methodology and ethos behind Ghostty.
While AI-assisted code can help with productive prototyping, creative
inspiration and even automated bugfinding, we have currently found zero
benefit to AI-generated assets. Instead, we are far more interested and
invested in funding professional work done by human designers and artists.
If you intend to submit AI-generated assets to Ghostty, sorry,
we are not interested.
Likewise, all community interactions, including all comments on issues and
discussions and all PR titles and descriptions **must be composed by a human**.
Community moderators and Ghostty maintainers reserve the right to mark
AI-generated responses as spam or disruptive content, and ban users who have
been repeatedly caught relying entirely on LLMs during interactions.
> [!NOTE]
> If your English isn't the best and you are currently relying on an LLM to
> translate your responses, don't fret — usually we maintainers will be able
> to understand your messages well enough. We'd like to encourage real humans
> to interact with each other more, and the positive impact of genuine,
> responsive yet imperfect human interaction more than makes up for any
> language barrier.
>
> Please write your responses yourself, to the best of your ability.
> If you do feel the need to polish your sentences, however, please use
> dedicated translation software rather than an LLM.
>
> We greatly appreciate it. Thank you. ❤️
Minor exceptions to this policy include trivial AI-generated tab completion
functionality, as it usually does not impact the quality of the code and
do not need to be disclosed, and commit titles and messages, which are often
generated by AI coding agents.
An example disclosure:
@ -60,13 +97,6 @@ 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.
## Quick Guide
@ -202,3 +232,266 @@ pull request will be accepted with a high degree of certainty.
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.
# Developer Guide
> [!NOTE]
>
> **The remainder of this file is dedicated to developers actively
> working on Ghostty.** If you're a user reporting an issue, you can
> ignore the rest of this document.
## Including and Updating Translations
See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details.
## Checking for Memory Leaks
While Zig does an amazing job of finding and preventing memory leaks,
Ghostty uses many third-party libraries that are written in C. Improper usage
of those libraries or bugs in those libraries can cause memory leaks that
Zig cannot detect by itself.
### On Linux
On Linux the recommended tool to check for memory leaks is Valgrind. The
recommended way to run Valgrind is via `zig build`:
```sh
zig build run-valgrind
```
This builds a Ghostty executable with Valgrind support and runs Valgrind
with the proper flags to ensure we're suppressing known false positives.
You can combine the same build args with `run-valgrind` that you can with
`run`, such as specifying additional configurations after a trailing `--`.
## Input Stack Testing
The input stack is the part of the codebase that starts with a
key event and ends with text encoding being sent to the pty (it
does not include _rendering_ the text, which is part of the
font or rendering stack).
If you modify any part of the input stack, you must manually verify
all the following input cases work properly. We unfortunately do
not automate this in any way, but if we can do that one day that'd
save a LOT of grief and time.
Note: this list may not be exhaustive, I'm still working on it.
### Linux IME
IME (Input Method Editors) are a common source of bugs in the input stack,
especially on Linux since there are multiple different IME systems
interacting with different windowing systems and application frameworks
all written by different organizations.
The following matrix should be tested to ensure that all IME input works
properly:
1. Wayland, X11
2. ibus, fcitx, none
3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex
4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors)
> [!NOTE]
>
> This is a **work in progress**. I'm still working on this list and it
> is not complete. As I find more test cases, I will add them here.
#### Dead Key Input
Set your keyboard layout to "Spanish" (or another layout that uses dead keys).
1. Launch Ghostty
2. Press `'`
3. Press `a`
4. Verify that `á` is displayed
Note that the dead key may or may not show a preedit state visually.
For ibus and fcitx it does but for the "none" case it does not. Importantly,
the text should be correct when it is sent to the pty.
We should also test canceling dead key input:
1. Launch Ghostty
2. Press `'`
3. Press escape
4. Press `a`
5. Verify that `a` is displayed (no diacritic)
#### CJK Input
Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The
exact layout doesn't matter.
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Enter`
5. Verify that `こん` is displayed in the terminal.
We should also test switching input methods while preedit is active, which
should commit the text:
1. Launch Ghostty
2. Press `Ctrl+Shift` to switch to "Hiragana"
3. On a US physical layout, type: `konn`, you should see `こん` in preedit.
4. Press `Ctrl+Shift` to switch to another layout (any)
5. Verify that `こん` is displayed in the terminal as committed text.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. They should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.
## Nix VM Integration Tests
Several Nix VM tests are provided by the project for testing Ghostty in a "live"
environment rather than just unit tests.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#check.<system>.<test-name>.driver`. `<system>` should be
`x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux
VM, not a macOS one). `<test-name>` should be one of the tests defined in
`nix/tests.nix`. The test will build and then launch. Depending on the speed
of your system, this can take a while. Eventually though the test should
complete. Hopefully successfully, but if not error messages should be printed
out that can be used to diagnose the issue.
3. To run _all_ of the tests, run `nix flake check`.
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a test.
### Interactively Running Test VMs
To run a test interactively, run `nix run
.#check.<system>.<test-name>.driverInteractive`. This will load a Python console
that can be used to manage the test VMs. In this console run `start_all()` to
start the VM(s). The VMs should boot up and a window should appear showing the
VM's console.
For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos)
### SSH Access to Test VMs
Some test VMs are configured to allow outside SSH access for debugging. To
access the VM, use a command like the following:
```
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1
```
The SSH options are important because the SSH host keys will be regenerated
every time the test is started. Without them, your personal SSH known hosts file
will become difficult to manage. The port that is needed to access the VM may
change depending on the test.
None of the users in the VM have passwords so do not expose these VMs to the Internet.

View File

@ -93,6 +93,36 @@ produced.
> may ask you to fix it and close the issue. It isn't a maintainers job to
> review a PR so broken that it requires significant rework to be acceptable.
## Logging
Ghostty can write logs to a number of destinations. On all platforms, logging to
`stderr` is available. Depending on the platform and how Ghostty was launched,
logs sent to `stderr` may be stored by the system and made available for later
retrieval.
On Linux if Ghostty is launched by the default `systemd` user service, you can use
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
On macOS logging to the macOS unified log is available and enabled by default.
Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
Ghostty's logging can be configured in two ways. The first is by what
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
any other optimization the debug logs will not be output to `stderr`.
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
to control which destinations receive logs. Ghostty currently defines two
destinations:
- `stderr` - logging to `stderr`.
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
## Linting
### Prettier

View File

@ -318,8 +318,3 @@ pub fn build(b: *std.Build) !void {
try translations_step.addError("cannot update translations when i18n is disabled", .{});
}
}
/// Marker used by Config.zig to detect if ghostty is the build root.
/// This avoids running logic such as Git tag checking when Ghostty
/// is used as a dependency.
pub const _ghostty_build_root = true;

View File

@ -15,13 +15,13 @@
},
.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",
.url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
.lazy = true,
},
@ -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,14 +50,14 @@
},
.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/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",
.url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
.hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-",
.lazy = true,
},

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

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

10
build.zig.zon.json generated
View File

@ -26,7 +26,7 @@
},
"gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": {
"name": "gobject",
"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",
"url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst",
"hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="
},
"N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": {
@ -116,12 +116,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": {
@ -141,12 +141,12 @@
},
"z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": {
"name": "z2d",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
"url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
"hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="
},
"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": {

10
build.zig.zon.nix generated
View File

@ -126,7 +126,7 @@ in
name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-";
path = fetchZigArtifact {
name = "gobject";
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";
url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst";
hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=";
};
}
@ -270,7 +270,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 +278,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=";
};
}
@ -310,7 +310,7 @@ in
name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T";
path = fetchZigArtifact {
name = "z2d";
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz";
url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz";
hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=";
};
}
@ -318,7 +318,7 @@ in
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=";
};
}

10
build.zig.zon.txt generated
View File

@ -6,6 +6,7 @@ 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/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
@ -19,17 +20,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/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.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/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
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

View File

@ -34,6 +34,27 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1755776884,
"narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.05",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1763191728,
@ -51,6 +72,7 @@
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs",
"zig": "zig",
"zon2nix": "zon2nix"

View File

@ -33,6 +33,13 @@
nixpkgs.follows = "nixpkgs";
};
};
home-manager = {
url = "github:nix-community/home-manager?ref=release-25.05";
inputs = {
nixpkgs.follows = "nixpkgs";
};
};
};
outputs = {
@ -40,6 +47,7 @@
nixpkgs,
zig,
zon2nix,
home-manager,
...
}:
builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
@ -47,7 +55,7 @@
system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell.${system} = pkgs.callPackage ./nix/devShell.nix {
devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix {
zig = zig.packages.${system}."0.15.2";
wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {};
zon2nix = zon2nix;
@ -79,6 +87,10 @@
formatter.${system} = pkgs.alejandra;
checks.${system} = import ./nix/tests.nix {
inherit home-manager nixpkgs self system;
};
apps.${system} = let
runVM = (
module: let
@ -95,6 +107,9 @@
in {
type = "app";
program = "${program}";
meta = {
description = "start a vm from ${toString module}";
};
}
);
in {
@ -120,11 +135,6 @@
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 = {

View File

@ -31,7 +31,7 @@
},
{
"type": "archive",
"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",
"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"
},
@ -139,13 +139,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 +169,13 @@
},
{
"type": "archive",
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz",
"url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
"dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
"sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10"
},
{
"type": "archive",
"url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz",
"dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh",
"sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568"
},

View File

@ -512,6 +512,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 +579,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;
@ -791,9 +803,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,
@ -837,6 +851,7 @@ typedef enum {
GHOSTTY_ACTION_END_SEARCH,
GHOSTTY_ACTION_SEARCH_TOTAL,
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
} ghostty_action_tag_e;
typedef union {
@ -845,6 +860,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;
@ -874,6 +890,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 {

View File

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

View File

@ -69,6 +69,7 @@ class AppDelegate: NSObject,
@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?
@ -98,11 +99,35 @@ class AppDelegate: NSObject,
/// 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()
@ -544,6 +569,7 @@ class AppDelegate: NSObject,
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
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")
@ -994,10 +1020,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
@ -1271,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation {
}
}
/// Represents the state of the quick terminal controller.
private enum QuickTerminalState {
/// Controller has not been initialized and has no pending restoration state.
case uninitialized
/// Restoration state is pending; controller will use this when first accessed.
case pendingRestore(QuickTerminalRestorableState)
/// Controller has been initialized.
case initialized(QuickTerminalController)
}
@globalActor
fileprivate actor AppIconActor: GlobalActor {
static let shared = AppIconActor()

View File

@ -47,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"/>
@ -328,6 +329,12 @@
<action selector="changeTitle:" target="-1" id="XuL-QB-Q9l"/>
</connections>
</menuItem>
<menuItem title="Terminal Read-only" id="xpe-ia-Yjw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleReadonly:" target="-1" id="Gqx-wT-K9v"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Vkj-tP-dMZ"/>
<menuItem title="Quick Terminal" id="1pv-LF-NBJ">
<modifierMask key="keyEquivalentModifierMask"/>

View File

@ -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.
@ -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,7 +596,7 @@ class QuickTerminalController: BaseTerminalController {
})
}
private func syncAppearance() {
override func syncAppearance() {
guard let window else { return }
defer { updateColorSchemeForSurfaceTree() }
@ -575,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 {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that

View File

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

View File

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

View File

@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController,
/// 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> = []
@ -621,9 +624,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
@ -807,6 +815,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.
@ -867,6 +904,9 @@ class BaseTerminalController: NSWindowController,
} else {
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
// Always resync our appearance
syncAppearance()
}
// MARK: Clipboard Confirmation
@ -1188,17 +1228,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
}
}
}

View File

@ -165,17 +165,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
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)
}
// MARK: Terminal Creation
/// Returns all the available terminal controllers present in the app currently.
@ -489,6 +478,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

View File

@ -1,10 +1,47 @@
import Cocoa
protocol TerminalRestorable: Codable {
static var selfKey: String { get }
static var versionKey: String { get }
static var version: Int { get }
init(copy other: Self)
/// 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 }
}
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
// version upgrading or something but for now we only have one version so we
// don't bother.
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
return nil
}
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
return nil
}
self.init(copy: v.value)
}
func encode(with coder: NSCoder) {
coder.encode(Self.version, forKey: Self.versionKey)
coder.encode(CodableBridge(self), forKey: Self.selfKey)
}
}
/// The state stored for terminal window restoration.
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 7
class TerminalRestorableState: TerminalRestorable {
class var version: Int { 7 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
@ -20,28 +57,12 @@ class TerminalRestorableState: Codable {
self.titleOverride = controller.titleOverride
}
init?(coder aDecoder: NSCoder) {
// If the version doesn't match then we can't decode. In the future we can perform
// version upgrading or something but for now we only have one version so we
// don't bother.
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
return nil
}
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
return nil
}
self.surfaceTree = v.value.surfaceTree
self.focusedSurface = v.value.focusedSurface
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
self.tabColor = v.value.tabColor
self.titleOverride = v.value.titleOverride
}
func encode(with coder: NSCoder) {
coder.encode(Self.version, forKey: Self.versionKey)
coder.encode(CodableBridge(self), forKey: Self.selfKey)
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
}
}
@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
}
}
}

View File

@ -44,6 +44,9 @@ 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
@ -466,7 +469,11 @@ 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) &&
!forceOpaque &&
surfaceConfig.backgroundOpacity < 1
{
isOpaque = false
@ -476,7 +483,15 @@ class TerminalWindow: NSWindow {
// Terminal.app more easily.
backgroundColor = .white.withAlphaComponent(0.001)
if let appDelegate = NSApp.delegate as? AppDelegate {
// Add liquid glass behind terminal content
if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle {
setupGlassLayer()
} else if let appDelegate = NSApp.delegate as? AppDelegate {
// If we had a prior glass layer we should remove it
if #available(macOS 26.0, *) {
removeGlassLayer()
}
ghostty_set_window_background_blur(
appDelegate.ghostty.app,
Unmanaged.passUnretained(self).toOpaque())
@ -484,6 +499,11 @@ class TerminalWindow: NSWindow {
} else {
isOpaque = true
// Remove liquid glass when not transparent
if #available(macOS 26.0, *) {
removeGlassLayer()
}
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
self.backgroundColor = backgroundColor.withAlphaComponent(1)
}
@ -562,19 +582,69 @@ class TerminalWindow: NSWindow {
}
}
#if compiler(>=6.2)
// MARK: Glass
@available(macOS 26.0, *)
private func setupGlassLayer() {
// Remove existing glass effect view
removeGlassLayer()
// Get the window content view (parent of the NSHostingView)
guard let contentView else { return }
guard let windowContentView = contentView.superview else { return }
// Create NSGlassEffectView for native glass effect
let effectView = NSGlassEffectView()
// Map Ghostty config to NSGlassEffectView style
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
default:
// Should not reach here since we check for glass style before calling
// setupGlassLayer()
assertionFailure()
}
effectView.cornerRadius = derivedConfig.windowCornerRadius
effectView.tintColor = preferredBackgroundColor
effectView.frame = windowContentView.bounds
effectView.autoresizingMask = [.width, .height]
// Position BELOW the terminal content to act as background
windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView)
glassEffectView = effectView
}
@available(macOS 26.0, *)
private func removeGlassLayer() {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
}
#endif // compiler(>=6.2)
// 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) {
@ -582,6 +652,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
}
}
}
}
@ -708,8 +790,8 @@ extension TerminalWindow {
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These are the target selectors, at least for macOS 26.
let tabContextSelectors: Set<String> = [
// These selectors must all exist for it to be a tab context menu.
let requiredSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
@ -717,7 +799,7 @@ extension TerminalWindow {
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return !selectorNames.isDisjoint(with: tabContextSelectors)
return requiredSelectors.isSubset(of: selectorNames)
}
private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) {

View File

@ -88,7 +88,16 @@ 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

View File

@ -501,6 +501,9 @@ 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)
@ -570,6 +573,9 @@ 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)
@ -588,6 +594,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)
@ -1010,6 +1019,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,
@ -1121,6 +1155,64 @@ 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,
@ -1286,6 +1378,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,

View File

@ -124,6 +124,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 +410,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 {
@ -626,6 +634,60 @@ 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:
self = .macosGlassRegular
case -2:
self = .macosGlassClear
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 +697,12 @@ extension Ghostty.Config {
static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
}
struct SplitPreserveZoom: OptionSet {
let rawValue: CUnsignedInt
static let navigation = SplitPreserveZoom(rawValue: 1 << 0)
}
enum MacDockDropBehavior: String {
case new_tab = "new-tab"

View File

@ -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)
}
@ -391,6 +391,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

View File

@ -116,6 +116,13 @@ extension Ghostty {
}
#if canImport(AppKit)
// Readonly indicator badge
if surfaceView.readonly {
ReadonlyBadge {
surfaceView.toggleReadonly(nil)
}
}
// 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 {
@ -757,6 +764,96 @@ extension Ghostty {
}
}
// MARK: Readonly Badge
/// A badge overlay that indicates a surface is in readonly mode.
/// Positioned in the top-right corner and styled to be noticeable but unobtrusive.
struct ReadonlyBadge: View {
let onDisable: () -> Void
@State private var showingPopover = false
private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8)
var body: some View {
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Image(systemName: "eye.fill")
.font(.system(size: 12))
Text("Read-only")
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(badgeBackground)
.foregroundStyle(badgeColor)
.onTapGesture {
showingPopover = true
}
.backport.pointerStyle(.link)
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover)
}
}
.padding(8)
Spacer()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Read-only terminal")
}
private var badgeBackground: some View {
RoundedRectangle(cornerRadius: 6)
.fill(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5)
)
}
}
struct ReadonlyPopoverView: View {
let onDisable: () -> Void
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "eye.fill")
.foregroundColor(.orange)
.font(.system(size: 13))
Text("Read-Only Mode")
.font(.system(size: 13, weight: .semibold))
}
Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Disable") {
onDisable()
isPresented = false
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
.frame(width: 280)
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't

View File

@ -123,6 +123,9 @@ 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
// 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
@ -333,6 +336,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),
@ -703,6 +711,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 }
@ -1416,6 +1429,9 @@ 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 Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "pencil.line")
@ -1499,6 +1515,14 @@ 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)")
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
@ -1975,6 +1999,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
case #selector(findHide):
return searchState != nil
case #selector(toggleReadonly):
item.state = readonly ? .on : .off
return true
default:
return true
}

View File

@ -43,6 +43,9 @@ extension Ghostty {
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.

View File

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

283
nix/tests.nix Normal file
View File

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

View File

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

View File

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

View File

@ -145,6 +145,12 @@ focused: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,
/// True if the surface is in read-only mode. When read-only, no input
/// is sent to the PTY but terminal-level operations like selections,
/// (native) scrolling, and copy keybinds still work. Warn before quit is
/// always enabled in this state.
readonly: bool = false,
/// Used to send notifications that long running commands have finished.
/// Requires that shell integration be active. Should represent a nanosecond
/// precision timestamp. It does not necessarily need to correspond to the
@ -601,6 +607,9 @@ pub fn init(
};
errdefer env.deinit();
// don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG");
// Initialize our IO backend
var io_exec = try termio.Exec.init(alloc, .{
.command = command,
@ -812,6 +821,30 @@ inline fn surfaceMailbox(self: *Surface) Mailbox {
};
}
/// Queue a message for the IO thread.
///
/// We centralize all our logic into this spot so we can intercept
/// messages for example in readonly mode.
fn queueIo(
self: *Surface,
msg: termio.Message,
mutex: termio.Termio.MutexState,
) void {
// In readonly mode, we don't allow any writes through to the pty.
if (self.readonly) {
switch (msg) {
.write_small,
.write_stable,
.write_alloc,
=> return,
else => {},
}
}
self.io.queueMessage(msg, mutex);
}
/// Forces the surface to render. This is useful for when the surface
/// is in the middle of animation (such as a resize, etc.) or when
/// the render timer is managed manually by the apprt.
@ -843,7 +876,7 @@ pub fn activateInspector(self: *Surface) !void {
// Notify our components we have an inspector active
_ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = true }, .unlocked);
self.queueIo(.{ .inspector = true }, .unlocked);
}
/// Deactivate the inspector and stop collecting any information.
@ -860,7 +893,7 @@ pub fn deactivateInspector(self: *Surface) void {
// Notify our components we have deactivated inspector
_ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = false }, .unlocked);
self.queueIo(.{ .inspector = false }, .unlocked);
// Deinit the inspector
insp.deinit();
@ -871,6 +904,9 @@ pub fn deactivateInspector(self: *Surface) void {
/// True if the surface requires confirmation to quit. This should be called
/// by apprt to determine if the surface should confirm before quitting.
pub fn needsConfirmQuit(self: *Surface) bool {
// If the surface is in read-only mode, always require confirmation
if (self.readonly) return true;
// If the child has exited, then our process is certainly not alive.
// We check this first to avoid the locking overhead below.
if (self.child_exited) return false;
@ -929,7 +965,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We always use an allocating message because we don't know
// the length of the title and this isn't a performance critical
// path.
self.io.queueMessage(.{
self.queueIo(.{
.write_alloc = .{
.alloc = self.alloc,
.data = data,
@ -1121,7 +1157,7 @@ fn selectionScrollTick(self: *Surface) !void {
// If our screen changed while this is happening, we stop our
// selection scroll.
if (self.mouse.left_click_screen != t.screens.active_key) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@ -1353,7 +1389,7 @@ fn reportColorScheme(self: *Surface, force: bool) void {
.dark => "\x1B[?997;1n",
};
self.io.queueMessage(.{ .write_stable = output }, .unlocked);
self.queueIo(.{ .write_stable = output }, .unlocked);
}
fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void {
@ -1726,7 +1762,7 @@ pub fn updateConfig(
errdefer termio_config_ptr.deinit();
_ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} });
self.io.queueMessage(.{
self.queueIo(.{
.change_config = .{
.alloc = self.alloc,
.ptr = termio_config_ptr,
@ -2001,6 +2037,23 @@ pub fn pwd(
return try alloc.dupe(u8, terminal_pwd);
}
/// Resolves a relative file path to an absolute path using the terminal's pwd.
fn resolvePathForOpening(
self: *Surface,
path: []const u8,
) Allocator.Error!?[]const u8 {
if (!std.fs.path.isAbsolute(path)) {
const terminal_pwd = self.io.terminal.getPwd() orelse {
return null;
};
const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path });
return resolved;
}
return null;
}
/// Returns the x/y coordinate of where the IME (Input Method Editor)
/// keyboard should be rendered.
pub fn imePoint(self: *const Surface) apprt.IMEPos {
@ -2292,7 +2345,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void {
self.balancePaddingIfNeeded();
// Notify the terminal
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
// Update our terminal default size if necessary.
self.recomputeInitialSize() catch |err| {
@ -2395,7 +2448,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void {
}
// Mail the IO thread
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
}
/// Recalculate the balanced padding if needed.
@ -2671,7 +2724,7 @@ pub fn keyCallback(
}
errdefer write_req.deinit();
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@ -2900,7 +2953,7 @@ fn endKeySequence(
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@ -3126,7 +3179,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
self.renderer_state.mutex.lock();
self.io.terminal.flags.focused = focused;
self.renderer_state.mutex.unlock();
self.io.queueMessage(.{ .focused = focused }, .unlocked);
self.queueIo(.{ .focused = focused }, .unlocked);
}
}
@ -3290,7 +3343,7 @@ pub fn scrollCallback(
};
};
for (0..y.magnitude()) |_| {
self.io.queueMessage(.{ .write_stable = seq }, .locked);
self.queueIo(.{ .write_stable = seq }, .locked);
}
}
@ -3511,7 +3564,7 @@ fn mouseReport(
data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1;
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = 6,
} }, .locked);
@ -3534,7 +3587,7 @@ fn mouseReport(
i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]);
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(i),
} }, .locked);
@ -3555,7 +3608,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3572,7 +3625,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3601,7 +3654,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3753,7 +3806,7 @@ pub fn mouseButtonCallback(
// Stop selection scrolling when releasing the left mouse button
// but only when selection scrolling is active.
if (self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.unlocked,
);
@ -4110,7 +4163,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
};
for (0..@abs(path.y)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
@ -4120,7 +4173,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
};
for (0..@abs(path.x)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
}
@ -4229,7 +4282,12 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false,
});
defer self.alloc.free(str);
try self.openUrl(.{ .kind = .unknown, .url = str });
const resolved_path = try self.resolvePathForOpening(str);
defer if (resolved_path) |p| self.alloc.free(p);
const url_to_open = resolved_path orelse str;
try self.openUrl(.{ .kind = .unknown, .url = url_to_open });
},
._open_osc8 => {
@ -4393,7 +4451,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active.
if (pos.y >= 1 and self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@ -4493,7 +4551,7 @@ pub fn cursorPosCallback(
if ((pos.y <= 1 or pos.y > max_y - 1) and
!self.selection_scroll_active)
{
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = true },
.locked,
);
@ -4869,7 +4927,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}),
else => unreachable,
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
full_data,
), .unlocked);
@ -4896,7 +4954,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
);
return true;
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
text,
), .unlocked);
@ -4929,9 +4987,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
if (normal) {
self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked);
self.queueIo(.{ .write_stable = ck.normal }, .unlocked);
} else {
self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked);
self.queueIo(.{ .write_stable = ck.application }, .unlocked);
}
},
@ -5204,19 +5262,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
if (self.io.terminal.screens.active_key == .alternate) return false;
}
self.io.queueMessage(.{
self.queueIo(.{
.clear_screen = .{ .history = true },
}, .unlocked);
},
.scroll_to_top => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .top = {} },
}, .unlocked);
},
.scroll_to_bottom => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .bottom = {} },
}, .unlocked);
},
@ -5246,14 +5304,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_up => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = -1 * rows },
}, .unlocked);
},
.scroll_page_down => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = rows },
}, .unlocked);
},
@ -5261,19 +5319,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_fractional => |fraction| {
const rows: f32 = @floatFromInt(self.size.grid().rows);
const delta: isize = @intFromFloat(@trunc(fraction * rows));
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = delta },
}, .unlocked);
},
.scroll_page_lines => |lines| {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = lines },
}, .unlocked);
},
.jump_to_prompt => |delta| {
self.io.queueMessage(.{
self.queueIo(.{
.jump_to_prompt = @intCast(delta),
}, .unlocked);
},
@ -5357,6 +5415,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
.goto_window => |direction| return try self.rt_app.performAction(
.{ .surface = self },
.goto_window,
switch (direction) {
.previous => .previous,
.next => .next,
},
),
.resize_split => |value| return try self.rt_app.performAction(
.{ .surface = self },
.resize_split,
@ -5383,6 +5450,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_readonly => {
self.readonly = !self.readonly;
_ = try self.rt_app.performAction(
.{ .surface = self },
.readonly,
if (self.readonly) .on else .off,
);
return true;
},
.reset_window_size => return try self.rt_app.performAction(
.{ .surface = self },
.reset_window_size,
@ -5441,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_background_opacity => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_background_opacity,
{},
),
.show_on_screen_keyboard => return try self.rt_app.performAction(
.{ .surface = self },
.show_on_screen_keyboard,
@ -5488,7 +5571,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
},
.io => self.io.queueMessage(.{ .crash = {} }, .unlocked),
.io => self.queueIo(.{ .crash = {} }, .unlocked),
},
.adjust_selection => |direction| {
@ -5686,7 +5769,7 @@ fn writeScreenFile(
},
.url = path,
}),
.paste => self.io.queueMessage(try termio.Message.writeReq(
.paste => self.queueIo(try termio.Message.writeReq(
self.alloc,
path,
), .unlocked),
@ -5826,7 +5909,7 @@ fn completeClipboardPaste(
};
for (vecs) |vec| if (vec.len > 0) {
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
vec,
), .unlocked);
@ -5872,7 +5955,7 @@ fn completeClipboardReadOSC52(
const encoded = enc.encode(buf[prefix.len..], data);
assert(encoded.len == size);
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
buf,
), .unlocked);

View File

@ -115,6 +115,11 @@ pub const Action = union(Key) {
/// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility,
/// Toggle the window background opacity. This only has an effect
/// if the window started as transparent (non-opaque), and toggles
/// it between fully opaque and the configured background opacity.
toggle_background_opacity,
/// Moves a tab by a relative offset.
///
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1
@ -129,6 +134,9 @@ pub const Action = union(Key) {
/// Jump to a specific split.
goto_split: GotoSplit,
/// Jump to next/previous window.
goto_window: GotoWindow,
/// Resize the split in the given direction.
resize_split: ResizeSplit,
@ -314,6 +322,9 @@ pub const Action = union(Key) {
/// The currently selected search match index (1-based).
search_selected: SearchSelected,
/// The readonly state of the surface has changed.
readonly: Readonly,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@ -329,9 +340,11 @@ pub const Action = union(Key) {
toggle_quick_terminal,
toggle_command_palette,
toggle_visibility,
toggle_background_opacity,
move_tab,
goto_tab,
goto_split,
goto_window,
resize_split,
equalize_splits,
toggle_split_zoom,
@ -375,6 +388,7 @@ pub const Action = union(Key) {
end_search,
search_total,
search_selected,
readonly,
};
/// Sync with: ghostty_action_u
@ -470,6 +484,13 @@ pub const GotoSplit = enum(c_int) {
right,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const GotoWindow = enum(c_int) {
previous,
next,
};
/// The amount to resize the split by and the direction to resize it in.
pub const ResizeSplit = extern struct {
amount: u16,
@ -532,6 +553,11 @@ pub const QuitTimer = enum(c_int) {
stop,
};
pub const Readonly = enum(c_int) {
off,
on,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,

View File

@ -8,6 +8,8 @@ const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const state = &@import("../../../global.zig").state;
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const cgroup = @import("../cgroup.zig");
@ -659,6 +661,8 @@ pub const Application = extern struct {
.goto_split => return Action.gotoSplit(target, value),
.goto_window => return Action.gotoWindow(value),
.goto_tab => return Action.gotoTab(target, value),
.initial_size => return Action.initialSize(target, value),
@ -737,6 +741,7 @@ pub const Application = extern struct {
.close_all_windows,
.float_window,
.toggle_visibility,
.toggle_background_opacity,
.cell_size,
.key_sequence,
.render_inspector,
@ -746,6 +751,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@ -2013,6 +2019,69 @@ const Action = struct {
}
}
pub fn gotoWindow(direction: apprt.action.GotoWindow) bool {
const glist = gtk.Window.listToplevels();
defer glist.free();
// The window we're starting from is typically our active window.
const starting: *glib.List = @as(?*glib.List, glist.findCustom(
null,
findActiveWindow,
)) orelse glist;
// Go forward or backwards in the list until we find a valid
// window that is visible.
var current_: ?*glib.List = starting;
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
// If we reached here, we didn't find a valid window to focus.
// Wrap around.
current_ = switch (direction) {
.next => glist,
.previous => last: {
var end: *glib.List = glist;
while (end.f_next) |next| end = next;
break :last end;
},
};
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
if (current_ == starting) break;
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
return false;
}
fn gotoWindowMaybe(gtk_window: *gtk.Window) bool {
// If it is already active skip it.
if (gtk_window.isActive() != 0) return false;
// If it is hidden, skip it.
if (gtk_window.as(gtk.Widget).isVisible() == 0) return false;
// If it isn't a Ghostty window, skip it.
const window = gobject.ext.cast(
Window,
gtk_window,
) orelse return false;
// Focus our active surface
const surface = window.getActiveSurface() orelse return false;
gtk.Window.present(gtk_window);
surface.grabFocus();
return true;
}
pub fn initialSize(
target: apprt.Target,
value: apprt.action.InitialSize,
@ -2611,7 +2680,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
/// disable it.
@"vulkan-disable": bool = false,
} = .{
.opengl = config.@"gtk-opengl-debug",
// `gtk-opengl-debug` dumps logs directly to stderr so both must be true
// to enable OpenGL debugging.
.opengl = state.logging.stderr and config.@"gtk-opengl-debug",
};
var gdk_disable: struct {

View File

@ -340,6 +340,35 @@ pub const SplitTree = extern struct {
const surface = tree.nodes[target.idx()].leaf;
surface.grabFocus();
// We also need to setup our last_focused to this because if we
// trigger a tree change like below, the grab focus above never
// actually triggers in time to set this and this ensures we
// grab focus to the right thing.
const old_last_focused = self.private().last_focused.get();
defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
self.private().last_focused.set(surface);
errdefer self.private().last_focused.set(old_last_focused);
if (tree.zoomed != null) {
const app = Application.default();
const config_obj = app.getConfig();
defer config_obj.unref();
const config = config_obj.get();
if (!config.@"split-preserve-zoom".navigation) {
tree.zoomed = null;
} else {
tree.zoom(target);
}
// When the zoom state changes our tree state changes and
// we need to send the proper notifications to trigger
// relayout.
const object = self.as(gobject.Object);
object.notifyByPspec(properties.tree.impl.param_spec);
object.notifyByPspec(properties.@"is-zoomed".impl.param_spec);
}
return true;
}

View File

@ -1658,13 +1658,7 @@ pub const Surface = extern struct {
};
priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len);
// Initialize our GLArea. We only set the values we can't set
// in our blueprint file.
const gl_area = priv.gl_area;
gl_area.setRequiredVersion(
renderer.OpenGL.MIN_VERSION_MAJOR,
renderer.OpenGL.MIN_VERSION_MINOR,
);
// Setup properties we can't set from our Blueprint file.
self.as(gtk.Widget).setCursorFromName("text");
// Initialize our config

View File

@ -793,7 +793,7 @@ pub const Window = extern struct {
/// Get the currently active surface. See the "active-surface" property.
/// This does not ref the value.
fn getActiveSurface(self: *Self) ?*Surface {
pub fn getActiveSurface(self: *Self) ?*Surface {
const tab = self.getSelectedTab() orelse return null;
return tab.getActiveSurface();
}

View File

@ -173,6 +173,12 @@ pub const Window = struct {
blur_region: Region = .{},
// Cache last applied values to avoid redundant X11 property updates.
// Redundant property updates seem to cause some visual glitches
// with some window managers: https://github.com/ghostty-org/ghostty/pull/8075
last_applied_blur_region: ?Region = null,
last_applied_decoration_hints: ?MotifWMHints = null,
pub fn init(
alloc: Allocator,
app: *App,
@ -255,30 +261,42 @@ pub const Window = struct {
const gtk_widget = self.apprt_window.as(gtk.Widget);
const config = if (self.apprt_window.getConfig()) |v| v.get() else return;
// When blur is disabled, remove the property if it was previously set
const blur = config.@"background-blur";
if (!blur.enabled()) {
if (self.last_applied_blur_region != null) {
try self.deleteProperty(self.app.atoms.kde_blur);
self.last_applied_blur_region = null;
}
return;
}
// Transform surface coordinates to device coordinates.
const scale = gtk_widget.getScaleFactor();
self.blur_region.width = gtk_widget.getWidth() * scale;
self.blur_region.height = gtk_widget.getHeight() * scale;
const blur = config.@"background-blur";
// Only update X11 properties when the blur region actually changes
if (self.last_applied_blur_region) |last| {
if (std.meta.eql(self.blur_region, last)) return;
}
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.x11_surface.getXid(),
self.blur_region,
});
if (blur.enabled()) {
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
} else {
try self.deleteProperty(self.app.atoms.kde_blur);
}
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
self.last_applied_blur_region = self.blur_region;
}
fn syncDecorations(self: *Window) !void {
@ -307,6 +325,11 @@ pub const Window = struct {
.auto, .client, .none => false,
};
// Only update decoration hints when they actually change
if (self.last_applied_decoration_hints) |last| {
if (std.meta.eql(hints, last)) return;
}
try self.changeProperty(
MotifWMHints,
self.app.atoms.motif_wm_hints,
@ -315,6 +338,7 @@ pub const Window = struct {
.{ .mode = .replace },
&hints,
);
self.last_applied_decoration_hints = hints;
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {

118
src/benchmark/OscParser.zig Normal file
View File

@ -0,0 +1,118 @@
//! This benchmark tests the throughput of the OSC parser.
const OscParser = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Parser = @import("../terminal/osc.zig").Parser;
const log = std.log.scoped(.@"osc-parser-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
parser: Parser,
pub const Options = struct {
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*OscParser {
const ptr = try alloc.create(OscParser);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.data_f = null,
.parser = .init(alloc),
};
return ptr;
}
pub fn destroy(self: *OscParser, alloc: Allocator) void {
self.parser.deinit();
alloc.destroy(self);
}
pub fn benchmark(self: *OscParser) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
self.parser.reset();
}
fn teardown(ptr: *anyopaque) void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var r = f.reader(&read_buf);
var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
while (true) {
r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
const len = r.interface.takeInt(usize, .little) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
if (len > osc_buf.len) return error.BenchmarkFailed;
r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
for (osc_buf[0..len]) |c| self.parser.next(c);
_ = self.parser.end(std.ascii.control_code.bel);
self.parser.reset();
}
}
test OscParser {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *OscParser = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@ -12,6 +12,7 @@ pub const Action = enum {
@"terminal-parser",
@"terminal-stream",
@"is-symbol",
@"osc-parser",
/// Returns the struct associated with the action. The struct
/// should have a few decls:
@ -29,6 +30,7 @@ pub const Action = enum {
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
.@"is-symbol" => @import("IsSymbol.zig"),
.@"osc-parser" => @import("OscParser.zig"),
};
}
};

View File

@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
else version: {
const app_version = try std.SemanticVersion.parse(appVersion);
// Detect if ghostty is being built as a dependency by checking if the
// build root has our marker. When used as a dependency, we skip git
// detection entirely to avoid reading the downstream project's git state.
const is_dependency = !@hasDecl(
@import("root"),
"_ghostty_build_root",
);
if (is_dependency) {
break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
}
// Is ghostty a dependency? If so, skip git detection.
// @src().file won't resolve from b.build_root unless ghostty
// is the project being built.
b.build_root.handle.access(@src().file, .{}) catch break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {

View File

@ -151,7 +151,7 @@ pub fn init(
// This overrides our default behavior and forces logs to show
// up on stderr (in addition to the centralized macOS log).
open.setEnvironmentVariable("GHOSTTY_LOG", "1");
open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos");
// Configure how we're launching
open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run");

View File

@ -37,6 +37,19 @@ precedence over the XDG environment locations.
: **WINDOWS ONLY:** alternate location to search for configuration files.
**GHOSTTY_LOG**
: The `GHOSTTY_LOG` environment variable 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.
# BUGS
See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>

View File

@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration.
## Configuration Errors
If your configuration file has any errors, Ghostty does its best to ignore
them and move on. Configuration errors currently show up in the log. The log
is written directly to stderr, so it is up to you to figure out how to access
that for your system (for now). On macOS, you can also use the system `log` CLI
utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
them and move on. Configuration errors will be logged.
## Debugging Configuration
You can verify that configuration is being properly loaded by looking at the
debug output of Ghostty. Documentation for how to view the debug output is in
the "building Ghostty" section at the end of the README.
debug output of Ghostty.
In the debug output, you should see in the first 20 lines or so messages about
loading (or not loading) a configuration file, as well as any errors it may have
@ -93,3 +89,34 @@ will fall back to default values for erroneous keys.
You can also view the full configuration Ghostty is loading using `ghostty
+show-config` from the command-line. Use the `--help` flag to additional options
for that command.
## 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.

View File

@ -604,7 +604,7 @@ pub fn parseAutoStruct(
return result;
}
fn parsePackedStruct(comptime T: type, v: []const u8) !T {
pub fn parsePackedStruct(comptime T: type, v: []const u8) !T {
const info = @typeInfo(T).@"struct";
comptime assert(info.layout == .@"packed");

View File

@ -2,6 +2,7 @@ const std = @import("std");
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Config = @import("../config/Config.zig");
const configpkg = @import("../config.zig");
const themepkg = @import("../config/theme.zig");
const tui = @import("tui.zig");
const global_state = &@import("../global.zig").state;
@ -196,6 +197,31 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
return 0;
}
fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 {
const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc);
defer alloc.free(main_cfg_path);
const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName;
return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" });
}
fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void {
const auto_path = try resolveAutoThemePath(alloc);
defer alloc.free(auto_path);
if (std.fs.path.dirname(auto_path)) |dir| {
try std.fs.cwd().makePath(dir);
}
var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true });
defer f.close();
var buf: [128]u8 = undefined;
var w = f.writer(&buf);
try w.interface.print("theme = {s}\n", .{theme_name});
try w.interface.flush();
}
const Event = union(enum) {
key_press: vaxis.Key,
mouse: vaxis.Mouse,
@ -487,6 +513,9 @@ const Preview = struct {
self.should_quit = true;
if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{}))
self.mode = .normal;
if (key.matches('w', .{})) {
self.saveSelectedTheme();
}
},
}
},
@ -698,7 +727,7 @@ const Preview = struct {
.help => {
win.hideCursor();
const width = 60;
const height = 20;
const height = 22;
const child = win.child(
.{
.x_off = win.width / 2 -| width / 2,
@ -733,6 +762,7 @@ const Preview = struct {
.{ .keys = "/", .help = "Start search." },
.{ .keys = "^X, ^/", .help = "Clear search." },
.{ .keys = "", .help = "Save theme or close search window." },
.{ .keys = "w", .help = "Write theme to auto config file." },
};
for (key_help, 0..) |help, captured_i| {
@ -786,8 +816,8 @@ const Preview = struct {
.save => {
const theme = self.themes[self.filtered.items[self.current]];
const width = 90;
const height = 12;
const width = 92;
const height = 17;
const child = win.child(
.{
.x_off = win.width / 2 -| width / 2,
@ -809,6 +839,12 @@ const Preview = struct {
try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}),
"",
"Save the configuration file and then reload it to apply the new theme.",
"",
"Or press 'w' to write an auto theme file to your system's preferred default config path.",
"Then add the following line to your Ghostty configuration and reload:",
"",
"config-file = ?auto/theme.ghostty",
"",
"For more details on configuration and themes, visit the Ghostty documentation:",
"",
"https://ghostty.org/docs/config/reference",
@ -1657,6 +1693,18 @@ const Preview = struct {
});
}
}
fn saveSelectedTheme(self: *Preview) void {
if (self.filtered.items.len == 0)
return;
const idx = self.filtered.items[self.current];
const theme = self.themes[idx];
writeAutoThemeFile(self.allocator, theme.theme) catch {
return;
};
}
};
fn color(config: Config, palette: usize) vaxis.Color {

View File

@ -86,6 +86,10 @@ pub const compatibility = std.StaticStringMap(
// Ghostty 1.2 removed the "desktop" option and renamed it to "detect".
// The semantics also changed slightly but this is the correct mapping.
.{ "gtk-single-instance", compatGtkSingleInstance },
// Ghostty 1.3 rename the "window" option to "new-window".
// See: https://github.com/ghostty-org/ghostty/pull/9764
.{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior },
});
/// The font families to use.
@ -927,6 +931,15 @@ palette: Palette = .{},
/// reasonable for a good looking blur. Higher blur intensities may
/// cause strange rendering and performance issues.
///
/// On macOS 26.0 and later, there are additional special values that
/// can be set to use the native macOS glass effects:
///
/// * `macos-glass-regular` - Standard glass effect with some opacity
/// * `macos-glass-clear` - Highly transparent glass effect
///
/// If the macOS values are set, then this implies `background-blur = true`
/// on non-macOS platforms.
///
/// Supported on macOS and on some Linux desktop environments, including:
///
/// * KDE Plasma (Wayland and X11)
@ -976,6 +989,22 @@ palette: Palette = .{},
/// Available since: 1.1.0
@"split-divider-color": ?Color = null,
/// Control when Ghostty preserves a zoomed split. Under normal circumstances,
/// any operation that changes focus or layout of the split tree in a window
/// will unzoom any zoomed split. This configuration allows you to control
/// this behavior.
///
/// This can be set to `navigation` to preserve the zoomed split state
/// when navigating to another split (e.g. via `goto_split`). This will
/// change the zoomed split to the newly focused split instead of unzooming.
///
/// Any options can also be prefixed with `no-` to disable that option.
///
/// Example: `split-preserve-zoom = navigation`
///
/// Available since: 1.3.0
@"split-preserve-zoom": SplitPreserveZoom = .{},
/// The foreground and background color for search matches. This only applies
/// to non-focused search matches, also known as candidate matches.
///
@ -1329,7 +1358,7 @@ maximize: bool = false,
/// new windows, not just the first one.
///
/// On macOS, this setting does not work if window-decoration is set to
/// "false", because native fullscreen on macOS requires window decorations
/// "none", because native fullscreen on macOS requires window decorations
/// to be set.
fullscreen: bool = false,
@ -2825,7 +2854,7 @@ keybind: Keybinds = .{},
/// also known as the traffic lights, that allow you to close, miniaturize, and
/// zoom the window.
///
/// This setting has no effect when `window-decoration = false` or
/// This setting has no effect when `window-decoration = none` or
/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in
/// these modes.
///
@ -2866,7 +2895,7 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways.
@ -2911,7 +2940,7 @@ keybind: Keybinds = .{},
///
/// * `new-tab` - Create a new tab in the current window, or open
/// a new window if none exist.
/// * `window` - Create a new window unconditionally.
/// * `new-window` - Create a new window unconditionally.
///
/// The default value is `new-tab`.
///
@ -3205,7 +3234,7 @@ else
/// manager's simple titlebar. The behavior of this option will vary with your
/// window manager.
///
/// This option does nothing when `window-decoration` is false or when running
/// This option does nothing when `window-decoration` is none or when running
/// under macOS.
@"gtk-titlebar": bool = true,
@ -4445,6 +4474,23 @@ fn compatBoldIsBright(
return true;
}
fn compatMacOSDockDropBehavior(
self: *Config,
alloc: Allocator,
key: []const u8,
value: ?[]const u8,
) bool {
_ = alloc;
assert(std.mem.eql(u8, key, "macos-dock-drop-behavior"));
if (std.mem.eql(u8, value orelse "", "window")) {
self.@"macos-dock-drop-behavior" = .@"new-window";
return true;
}
return false;
}
/// Add a diagnostic message to the config with the given string.
/// This is always added with a location of "none".
pub fn addDiagnosticFmt(
@ -7414,6 +7460,10 @@ pub const ShellIntegrationFeatures = packed struct {
path: bool = true,
};
pub const SplitPreserveZoom = packed struct {
navigation: bool = false,
};
pub const RepeatableCommand = struct {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
@ -7875,7 +7925,7 @@ pub const WindowNewTabPosition = enum {
/// See macos-dock-drop-behavior
pub const MacOSDockDropBehavior = enum {
@"new-tab",
window,
@"new-window",
};
/// See window-show-tab-bar
@ -8315,6 +8365,8 @@ pub const AutoUpdate = enum {
pub const BackgroundBlur = union(enum) {
false,
true,
@"macos-glass-regular",
@"macos-glass-clear",
radius: u8,
pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void {
@ -8324,14 +8376,35 @@ pub const BackgroundBlur = union(enum) {
return;
};
self.* = if (cli.args.parseBool(input_)) |b|
if (b) .true else .false
else |_|
.{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
// Try to parse normal bools
if (cli.args.parseBool(input_)) |b| {
self.* = if (b) .true else .false;
return;
} else |_| {}
// Try to parse enums
if (std.meta.stringToEnum(
std.meta.Tag(BackgroundBlur),
input_,
)) |v| switch (v) {
inline else => |tag| tag: {
// We can only parse void types
const info = std.meta.fieldInfo(BackgroundBlur, tag);
if (info.type != void) break :tag;
self.* = @unionInit(
BackgroundBlur,
@tagName(tag),
{},
);
return;
},
};
self.* = .{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
}
pub fn enabled(self: BackgroundBlur) bool {
@ -8339,14 +8412,24 @@ pub const BackgroundBlur = union(enum) {
.false => false,
.true => true,
.radius => |v| v > 0,
// We treat these as true because they both imply some blur!
// This has the effect of making the standard blur happen on
// Linux.
.@"macos-glass-regular", .@"macos-glass-clear" => true,
};
}
pub fn cval(self: BackgroundBlur) u8 {
pub fn cval(self: BackgroundBlur) i16 {
return switch (self) {
.false => 0,
.true => 20,
.radius => |v| v,
// I hate sentinel values like this but this is only for
// our macOS application currently. We can switch to a proper
// tagged union if we ever need to.
.@"macos-glass-regular" => -1,
.@"macos-glass-clear" => -2,
};
}
@ -8358,6 +8441,8 @@ pub const BackgroundBlur = union(enum) {
.false => try formatter.formatEntry(bool, false),
.true => try formatter.formatEntry(bool, true),
.radius => |v| try formatter.formatEntry(u8, v),
.@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"),
.@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"),
}
}
@ -8377,6 +8462,12 @@ pub const BackgroundBlur = union(enum) {
try v.parseCLI("42");
try testing.expectEqual(42, v.radius);
try v.parseCLI("macos-glass-regular");
try testing.expectEqual(.@"macos-glass-regular", v);
try v.parseCLI("macos-glass-clear");
try testing.expectEqual(.@"macos-glass-clear", v);
try testing.expectError(error.InvalidValue, v.parseCLI(""));
try testing.expectError(error.InvalidValue, v.parseCLI("aaaa"));
try testing.expectError(error.InvalidValue, v.parseCLI("420"));
@ -9491,3 +9582,22 @@ test "compatibility: removed bold-is-bright" {
);
}
}
test "compatibility: window new-window" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--macos-dock-drop-behavior=window",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expectEqual(
MacOSDockDropBehavior.@"new-window",
cfg.@"macos-dock-drop-behavior",
);
}
}

View File

@ -193,20 +193,48 @@ test "c_get: background-blur" {
{
c.@"background-blur" = .false;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur" = .true;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
{
c.@"background-blur" = .@"macos-glass-regular";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-1, cval);
}
{
c.@"background-blur" = .@"macos-glass-clear";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-2, cval);
}
}
test "c_get: split-preserve-zoom" {
const testing = std.testing;
const alloc = testing.allocator;
var c = try Config.default(alloc);
defer c.deinit();
var bits: c_uint = undefined;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 0), bits);
c.@"split-preserve-zoom".navigation = true;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 1), bits);
}

View File

@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)[\w\-.~:\/?#@!$&*+,;=%]+(?:\/[\w\-.~:\/?#@!$&*+,;=%]*)*
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@ -194,7 +194,7 @@ test "url regex" {
},
.{
.input = "../example.py ",
.expect = "../example.py",
.expect = "../example.py ",
},
.{
.input = "first time ../example.py contributor ",
@ -253,6 +253,23 @@ test "url regex" {
.input = "IPv6 in markdown [link](http://[2001:db8::1]/docs)",
.expect = "http://[2001:db8::1]/docs",
},
// File paths with spaces
.{
.input = "./spaces-end. ",
.expect = "./spaces-end. ",
},
.{
.input = "./space middle",
.expect = "./space middle",
},
.{
.input = "../test folder/file.txt",
.expect = "../test folder/file.txt",
},
.{
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
};
for (cases) |case| {

View File

@ -39,9 +39,13 @@ pub const GlobalState = struct {
resources_dir: internal_os.ResourcesDir,
/// Where logging should go
pub const Logging = union(enum) {
disabled: void,
stderr: void,
pub const Logging = packed struct {
/// Whether to log to stderr. For lib mode we always disable stderr
/// logging by default. Otherwise it's enabled by default.
stderr: bool = build_config.app_runtime != .none,
/// Whether to log to macOS's unified logging. Enabled by default
/// on macOS.
macos: bool = builtin.os.tag.isDarwin(),
};
/// Initialize the global state.
@ -61,7 +65,7 @@ pub const GlobalState = struct {
.gpa = null,
.alloc = undefined,
.action = null,
.logging = .{ .stderr = {} },
.logging = .{},
.rlimits = .{},
.resources_dir = .{},
};
@ -100,12 +104,7 @@ pub const GlobalState = struct {
// If we have an action executing, we disable logging by default
// since we write to stderr we don't want logs messing up our
// output.
if (self.action != null) self.logging = .{ .disabled = {} };
// For lib mode we always disable stderr logging by default.
if (comptime build_config.app_runtime == .none) {
self.logging = .{ .disabled = {} };
}
if (self.action != null) self.logging.stderr = false;
// I don't love the env var name but I don't have it in my heart
// to parse CLI args 3 times (once for actions, once for config,
@ -114,9 +113,7 @@ pub const GlobalState = struct {
// easy to set.
if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| {
defer v.deinit(self.alloc);
if (v.value.len > 0) {
self.logging = .{ .stderr = {} };
}
self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{};
}
// Setup our signal handlers before logging

View File

@ -545,6 +545,9 @@ pub const Action = union(enum) {
/// (`previous` and `next`).
goto_split: SplitFocusDirection,
/// Focus on either the previous window or the next one ('previous', 'next')
goto_window: GotoWindow,
/// Zoom in or out of the current split.
///
/// When a split is zoomed into, it will take up the entire space in
@ -552,6 +555,16 @@ pub const Action = union(enum) {
/// reflect this by displaying an icon indicating the zoomed state.
toggle_split_zoom,
/// Toggle read-only mode for the current surface.
///
/// When a surface is in read-only mode:
/// - No input is sent to the PTY (mouse events, key encoding)
/// - Input can still be used at the terminal level to make selections,
/// copy/paste (keybinds), scroll, etc.
/// - Warn before quit is always enabled in this state even if an active
/// process is not running
toggle_readonly,
/// Resize the current split in the specified direction and amount in
/// pixels. The two arguments should be joined with a comma (`,`),
/// like in `resize_split:up,10`.
@ -742,6 +755,16 @@ pub const Action = union(enum) {
/// Only implemented on macOS.
toggle_visibility,
/// Toggle the window background opacity between transparent and opaque.
///
/// This does nothing when `background-opacity` is set to 1 or above.
///
/// When `background-opacity` is less than 1, this action will either make
/// the window transparent or not depending on its current transparency state.
///
/// Only implemented on macOS.
toggle_background_opacity,
/// Check for updates.
///
/// Only implemented on macOS.
@ -921,6 +944,11 @@ pub const Action = union(enum) {
right,
};
pub const GotoWindow = enum {
previous,
next,
};
pub const SplitResizeParameter = struct {
SplitResizeDirection,
u16,
@ -1222,6 +1250,7 @@ pub const Action = union(enum) {
.toggle_secure_input,
.toggle_mouse_reporting,
.toggle_command_palette,
.toggle_background_opacity,
.show_on_screen_keyboard,
.reset_window_size,
.crash,
@ -1240,7 +1269,9 @@ pub const Action = union(enum) {
.toggle_tab_overview,
.new_split,
.goto_split,
.goto_window,
.toggle_split_zoom,
.toggle_readonly,
.resize_split,
.equalize_splits,
.inspector,

View File

@ -479,12 +479,31 @@ fn actionCommands(action: Action.Key) []const Command {
},
},
.goto_window => comptime &.{
.{
.action = .{ .goto_window = .previous },
.title = "Focus Window: Previous",
.description = "Focus the previous window, if any.",
},
.{
.action = .{ .goto_window = .next },
.title = "Focus Window: Next",
.description = "Focus the next window, if any.",
},
},
.toggle_split_zoom => comptime &.{.{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
.description = "Toggle the zoom state of the current split.",
}},
.toggle_readonly => comptime &.{.{
.action = .toggle_readonly,
.title = "Toggle Read-Only Mode",
.description = "Toggle read-only mode for the current surface.",
}},
.equalize_splits => comptime &.{.{
.action = .equalize_splits,
.title = "Equalize Splits",
@ -599,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Toggle whether mouse events are reported to terminal applications.",
}},
.toggle_background_opacity => comptime &.{.{
.action = .toggle_background_opacity,
.title = "Toggle Background Opacity",
.description = "Toggle the background opacity of a window that started transparent.",
}},
.check_for_updates => comptime &.{.{
.action = .check_for_updates,
.title = "Check for Updates",

View File

@ -178,7 +178,7 @@ fn kitty(
// Quote ("report all" mode):
// Note that all keys are reported as escape codes, including Enter,
// Tab, Backspace etc.
if (effective_mods.empty()) {
if (binding_mods.empty()) {
switch (event.key) {
.enter => return try writer.writeByte('\r'),
.tab => return try writer.writeByte('\t'),
@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" {
try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered());
}
}
//
test "kitty: shift+backspace emits CSI u" {
// Backspace with shift modifier should emit CSI u sequence, not raw 0x7F.
// This is important for programs that want to distinguish shift+backspace.
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .backspace,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[127;2u", writer.buffered());
}
test "kitty: shift+enter emits CSI u" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .enter,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[13;2u", writer.buffered());
}
test "kitty: shift+tab emits CSI u" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .tab,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[9;2u", writer.buffered());
}
test "kitty: enter with all flags" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);

View File

@ -118,19 +118,17 @@ fn logFn(
comptime format: []const u8,
args: anytype,
) void {
// Stuff we can do before the lock
const level_txt = comptime level.asText();
const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
// Lock so we are thread-safe
std.debug.lockStdErr();
defer std.debug.unlockStdErr();
// On Mac, we use unified logging. To view this:
//
// sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'
//
if (builtin.target.os.tag.isDarwin()) {
// macOS logging is thread safe so no need for locks/mutexes
macos: {
if (comptime !builtin.target.os.tag.isDarwin()) break :macos;
if (!state.logging.macos) break :macos;
const prefix = if (scope == .default) "" else @tagName(scope) ++ ": ";
// Convert our levels to Mac levels
const mac_level: macos.os.LogType = switch (level) {
.debug => .debug,
@ -143,26 +141,35 @@ fn logFn(
// but we shouldn't be logging too much.
const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope));
defer logger.release();
logger.log(std.heap.c_allocator, mac_level, format, args);
logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args);
}
switch (state.logging) {
.disabled => {},
stderr: {
// don't log debug messages to stderr unless we are a debug build
if (comptime builtin.mode != .Debug and level == .debug) break :stderr;
.stderr => {
// Always try default to send to stderr
var buffer: [1024]u8 = undefined;
var stderr = std.fs.File.stderr().writer(&buffer);
const writer = &stderr.interface;
nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return;
// TODO: Do we want to use flushless stderr in the future?
writer.flush() catch {};
},
// skip if we are not logging to stderr
if (!state.logging.stderr) break :stderr;
// Lock so we are thread-safe
var buf: [64]u8 = undefined;
const stderr = std.debug.lockStderrWriter(&buf);
defer std.debug.unlockStderrWriter();
const level_txt = comptime level.asText();
const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr;
nosuspend stderr.flush() catch break :stderr;
}
}
pub const std_options: std.Options = .{
// Our log level is always at least info in every build mode.
//
// Note, we don't lower this to debug even with conditional logging
// via GHOSTTY_LOG because our debug logs are very expensive to
// calculate and we want to make sure they're optimized out in
// builds.
.log_level = switch (builtin.mode) {
.Debug => .debug,
else => .info,

View File

@ -1,7 +1,84 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
/// Builder for constructing space-separated shell command strings.
/// Uses a caller-provided allocator (typically with stackFallback).
pub const ShellCommandBuilder = struct {
buffer: std.Io.Writer.Allocating,
pub fn init(allocator: Allocator) ShellCommandBuilder {
return .{ .buffer = .init(allocator) };
}
pub fn deinit(self: *ShellCommandBuilder) void {
self.buffer.deinit();
}
/// Append an argument to the command with automatic space separation.
pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void {
if (arg.len == 0) return;
if (self.buffer.written().len > 0) {
try self.buffer.writer.writeByte(' ');
}
try self.buffer.writer.writeAll(arg);
}
/// Get the final null-terminated command string, transferring ownership to caller.
/// Calling deinit() after this is safe but unnecessary.
pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 {
return try self.buffer.toOwnedSliceSentinel(0);
}
};
test ShellCommandBuilder {
// Empty command
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try testing.expectEqualStrings("", cmd.buffer.written());
}
// Single arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// Multiple args
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
try cmd.appendArg("-l");
try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written());
}
// Empty arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// toOwnedSlice
{
var cmd = ShellCommandBuilder.init(testing.allocator);
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
const result = try cmd.toOwnedSlice();
defer testing.allocator.free(result);
try testing.expectEqualStrings("bash --posix", result);
try testing.expectEqual(@as(u8, 0), result[result.len]);
}
}
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.

View File

@ -265,3 +265,16 @@ test "percent 7" {
@memcpy(&src, s);
try std.testing.expectError(error.DecodeError, urlPercentDecode(&src));
}
/// Is the given character valid in URI percent encoding?
fn isValidChar(c: u8) bool {
return switch (c) {
' ', ';', '=' => false,
else => return std.ascii.isPrint(c),
};
}
/// Write data to the writer after URI percent encoding.
pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void {
try std.Uri.Component.percentEncode(writer, data, isValidChar);
}

View File

@ -137,15 +137,7 @@ fn prepareContext(getProcAddress: anytype) !void {
errdefer gl.glad.unload();
log.info("loaded OpenGL {}.{}", .{ major, minor });
// Enable debug output for the context.
try gl.enable(gl.c.GL_DEBUG_OUTPUT);
// Register our debug message callback with the OpenGL context.
gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null);
// Enable SRGB framebuffer for linear blending support.
try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
// Need to check version before trying to enable it
if (major < MIN_VERSION_MAJOR or
(major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR))
{
@ -155,6 +147,15 @@ fn prepareContext(getProcAddress: anytype) !void {
);
return error.OpenGLOutdated;
}
// Enable debug output for the context.
try gl.enable(gl.c.GL_DEBUG_OUTPUT);
// Register our debug message callback with the OpenGL context.
gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null);
// Enable SRGB framebuffer for linear blending support.
try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
}
/// This is called early right after surface creation.

View File

@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
vsync: bool,
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
pub fn init(
alloc_gpa: Allocator,
@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.vsync = config.@"window-vsync",
.colorspace = config.@"window-colorspace",
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.arena = arena,
};
}
@ -716,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
options.config.background.r,
options.config.background.g,
options.config.background.b,
// Note that if we're on macOS with glass effects
// we'll disable background opacity but we handle
// that in updateFrame.
@intFromFloat(@round(options.config.background_opacity * 255.0)),
},
.bools = .{
@ -1295,6 +1300,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.terminal_state.colors.background.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)),
};
// If we're on macOS and have glass styles, we remove
// the background opacity because the glass effect handles
// it.
if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) {
.@"macos-glass-regular",
.@"macos-glass-clear",
=> self.uniforms.bg_color[3] = 0,
else => {},
};
}
}

View File

@ -44,6 +44,15 @@ pub const Size = struct {
self.grid(),
self.cell,
);
// The top/bottom padding is interesting. Subjectively, lots of padding
// at the top looks bad. So instead of always being equal (like left/right),
// we force the top padding to be at most equal to the maximum left padding,
// which is the balanced explicit horizontal padding plus half a cell width.
const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2;
const vshift = self.padding.top -| max_padding_left;
self.padding.top -= vshift;
self.padding.bottom += vshift;
}
};
@ -258,16 +267,12 @@ pub const Padding = struct {
const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width;
const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height;
// The left/right padding is just an equal split.
// The padding is split equally along both axes.
const padding_right = @floor(space_right / 2);
const padding_left = padding_right;
// The top/bottom padding is interesting. Subjectively, lots of padding
// at the top looks bad. So instead of always being equal (like left/right),
// we force the top padding to be at most equal to the left, and the bottom
// padding is the difference thereafter.
const padding_top = @min(padding_left, @floor(space_bot / 2));
const padding_bot = space_bot - padding_top;
const padding_bot = @floor(space_bot / 2);
const padding_top = padding_bot;
const zero = @as(f32, 0);
return .{

View File

@ -78,10 +78,16 @@ on the Fish startup process, see the
### Zsh
For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration
from the `zsh` directory. The existing `ZDOTDIR` is retained so that
after loading the Ghostty shell integration the normal Zsh loading
sequence occurs.
Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting
`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable
value will be retained and restored after our shell integration scripts are
run.
However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will
override Ghostty's `ZDOTDIR` value, preventing the shell integration from being
loaded. In this case, the shell integration needs to be loaded manually.
To load the Zsh shell integration manually:
```zsh
if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then

View File

@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
builtin command sudo --preserve-env=TERMINFO "$@";
fi
}
fi

View File

@ -97,7 +97,7 @@
if (not (has-value $arg =)) { break }
}
if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] }
if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] }
(external sudo) $@args
}

View File

@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
if test "$sudo_has_sudoedit_flags" = "yes"
command sudo $argv
else
command sudo TERMINFO="$TERMINFO" $argv
command sudo --preserve-env=TERMINFO $argv
end
end
end

View File

@ -93,9 +93,6 @@ _entrypoint() {
_ghostty_deferred_init() {
builtin emulate -L zsh -o no_warn_create_global -o no_aliases
# The directory where ghostty-integration is located: /../shell-integration/zsh.
builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}"
# Enable semantic markup with OSC 133.
_ghostty_precmd() {
builtin local -i cmd_status=$?
@ -255,7 +252,7 @@ _ghostty_deferred_init() {
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
builtin command sudo --preserve-env=TERMINFO "$@";
fi
}
fi

View File

@ -5,12 +5,23 @@ const std = @import("std");
const assert = std.debug.assert;
const Generator = @import("Generator.zig");
const Bytes = @import("Bytes.zig");
const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode;
/// Valid OSC request kinds that can be generated.
pub const ValidKind = enum {
change_window_title,
prompt_start,
prompt_end,
end_of_input,
end_of_command,
rxvt_notify,
mouse_shape,
clipboard_operation,
report_pwd,
hyperlink_start,
hyperlink_end,
conemu_progress,
iterm2_notification,
};
/// Invalid OSC request kinds that can be generated.
@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool {
/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07).
pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet);
pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint);
pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic);
pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric);
pub fn generator(self: *Osc) Generator {
return .init(self, next);
@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin
if (max_len < 4) break :prompt_end;
try writer.writeAll("133;B"); // End prompt
},
.end_of_input => end_of_input: {
if (max_len < 5) break :end_of_input;
var remaining = max_len;
try writer.writeAll("133;C"); // End prompt
remaining -= 5;
if (self.rand.boolean()) cmdline: {
const prefix = ";cmdline_url=";
if (remaining < prefix.len + 1) break :cmdline;
try writer.writeAll(prefix);
remaining -= prefix.len;
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w);
try urlPercentEncode(writer, w.buffered());
remaining -= w.buffered().len;
}
},
.end_of_command => end_of_command: {
if (max_len < 4) break :end_of_command;
try writer.writeAll("133;D"); // End prompt
if (self.rand.boolean()) exit_code: {
if (max_len < 7) break :exit_code;
try writer.print(";{d}", .{self.rand.int(u8)});
}
},
.mouse_shape => mouse_shape: {
if (max_len < 4) break :mouse_shape;
try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt
},
.rxvt_notify => rxvt_notify: {
const prefix = "777;notify;";
if (max_len < prefix.len) break :rxvt_notify;
var remaining = max_len;
try writer.writeAll(prefix);
remaining -= prefix.len;
remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer);
try writer.writeByte(';');
remaining -= 1;
remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer);
},
.clipboard_operation => {
try writer.writeAll("52;");
var remaining = max_len - 3;
if (self.rand.boolean()) {
remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer);
}
try writer.writeByte(';');
remaining -= 1;
if (self.rand.boolean()) {
remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer);
}
},
.report_pwd => report_pwd: {
const prefix = "7;file://localhost";
if (max_len < prefix.len) break :report_pwd;
var remaining = max_len;
try writer.writeAll(prefix);
remaining -= prefix.len;
for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| {
try writer.writeByte('/');
remaining -= 1;
remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer);
}
},
.hyperlink_start => {
try writer.writeAll("8;");
if (self.rand.boolean()) {
try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)});
}
try writer.writeAll(";https://localhost");
for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| {
try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)});
}
},
.hyperlink_end => hyperlink_end: {
if (max_len < 3) break :hyperlink_end;
try writer.writeAll("8;;");
},
.conemu_progress => {
try writer.writeAll("9;");
switch (self.rand.intRangeAtMost(u3, 0, 4)) {
0, 3 => |c| {
try writer.print(";{d}", .{c});
},
1, 2, 4 => |c| {
if (self.rand.boolean()) {
try writer.print(";{d}", .{c});
} else {
try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) });
}
},
else => unreachable,
}
},
.iterm2_notification => iterm2_notification: {
if (max_len < 3) break :iterm2_notification;
// add a prefix to ensure that this is not interpreted as a ConEmu OSC
try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)});
},
}
}

View File

@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void {
var gen: Bytes = .{
.rand = rand,
.alphabet = ascii,
.min_len = 1024,
.max_len = 1024,
};
while (true) {
gen.next(writer, 1024) catch |err| {
_ = gen.write(writer) catch |err| {
const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed

View File

@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {
/// Probability of generating a valid value.
@"p-valid": f64 = 0.5,
style: enum {
/// Write all OSC data, including ESC ] and ST for end-to-end tests
streaming,
/// Only write data, prefixed with a length, used for testing just the
/// OSC parser.
parser,
} = .streaming,
};
opts: Options,
@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void {
var fixed: std.Io.Writer = .fixed(&buf);
try gen.next(&fixed, buf.len);
const data = fixed.buffered();
writer.writeAll(data) catch |err| switch (err) {
error.WriteFailed => return,
};
switch (self.opts.style) {
.streaming => {
writer.writeAll(data) catch |err| switch (err) {
error.WriteFailed => return,
};
},
.parser => {
writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) {
error.WriteFailed => return,
};
writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) {
error.WriteFailed => return,
};
},
}
}
}

View File

@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void {
// this check.
!self.screens.active.blankCell().isZero())
{
self.scrollUp(1);
try self.scrollUp(1);
return;
}
@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void {
/// The new lines are created according to the current SGR state.
///
/// Does not change the (absolute) cursor position.
pub fn scrollUp(self: *Terminal, count: usize) void {
pub fn scrollUp(self: *Terminal, count: usize) !void {
// Preserve our x/y to restore.
const old_x = self.screens.active.cursor.x;
const old_y = self.screens.active.cursor.y;
@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void {
self.screens.active.cursor.pending_wrap = old_wrap;
}
// If our scroll region is at the top and we have no left/right
// margins then we move the scrolled out text into the scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
self.scrolling_region.right == self.cols - 1)
{
// Scrolling dirties the images because it updates their placements pins.
if (comptime build_options.kitty_graphics) {
self.screens.active.kitty_images.dirty = true;
}
// Clamp count to the scroll region height.
const region_height = self.scrolling_region.bottom + 1;
const adjusted_count = @min(count, region_height);
// TODO: Create an optimized version that can scroll N times
// This isn't critical because in most cases, scrollUp is used
// with count=1, but it's still a big optimization opportunity.
// Move our cursor to the bottom of the scroll region so we can
// use the cursorScrollAbove function to create scrollback
self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom);
for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove();
return;
}
// Move to the top of the scroll region
self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top);
self.deleteLines(count);
@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" {
t.setCursorPos(2, 2);
const cursor = t.screens.active.cursor;
t.clearDirty();
t.scrollUp(1);
const viewport_before = t.screens.active.pages.getTopLeft(.viewport);
try t.scrollUp(1);
try testing.expectEqual(cursor.x, t.screens.active.cursor.x);
try testing.expectEqual(cursor.y, t.screens.active.cursor.y);
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
// Viewport should have moved. Our entire page should've scrolled!
// The viewport moving will cause our render state to make the full
// frame as dirty.
const viewport_after = t.screens.active.pages.getTopLeft(.viewport);
try testing.expect(!viewport_before.eql(viewport_after));
{
const str = try t.plainString(testing.allocator);
@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" {
try t.linefeed();
try t.printString("GHI");
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" {
try t.linefeed();
try t.printString("GHI");
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" {
t.setCursorPos(1, 1);
t.clearDirty();
t.scrollUp(1);
try t.scrollUp(1);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" {
const cursor = t.screens.active.cursor;
t.clearDirty();
t.scrollUp(1);
try t.scrollUp(1);
try testing.expectEqual(cursor.x, t.screens.active.cursor.x);
try testing.expectEqual(cursor.y, t.screens.active.cursor.y);
@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
t.scrolling_region.left = 1;
t.scrolling_region.right = 3;
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" {
try t.print('B');
t.setCursorPos(3, 5);
try t.print('C');
t.scrollUp(1);
try t.scrollUp(1);
try t.print('X');
{
@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" {
t.setTopAndBottomMargin(2, 5);
t.clearDirty();
t.scrollUp(4);
try t.scrollUp(4);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" {
t.setLeftAndRightMargin(2, 4);
t.clearDirty();
t.scrollUp(4);
try t.scrollUp(4);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" {
}
}
test "Terminal: scrollUp creates scrollback in primary screen" {
// When in primary screen with full-width scroll region at top,
// scrollUp (CSI S) should push lines into scrollback like xterm.
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 });
defer t.deinit(alloc);
// Fill the screen with content
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
t.carriageReturn();
try t.linefeed();
try t.printString("DDDDD");
t.carriageReturn();
try t.linefeed();
try t.printString("EEEEE");
t.clearDirty();
// Scroll up by 1, which should push "AAAAA" into scrollback
try t.scrollUp(1);
// The cursor row (new empty row) should be dirty
try testing.expect(t.screens.active.cursor.page_row.dirty);
// The active screen should now show BBBBB through EEEEE plus one blank line
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str);
}
// Now scroll to the top to see scrollback - AAAAA should be there
t.screens.active.scroll(.{ .top = {} });
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// Should see AAAAA in scrollback
try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str);
}
}
test "Terminal: scrollUp with max_scrollback zero" {
// When max_scrollback is 0, scrollUp should still work but not retain history
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
try t.scrollUp(1);
// Active screen should show scrolled content
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC", str);
}
// Scroll to top - should be same as active since no scrollback
t.screens.active.scroll(.{ .top = {} });
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC", str);
}
}
test "Terminal: scrollUp with max_scrollback zero and top margin" {
// When max_scrollback is 0 and top margin is set, should use deleteLines path
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
t.carriageReturn();
try t.linefeed();
try t.printString("DDDDD");
// Set top margin (not at row 0)
t.setTopAndBottomMargin(2, 5);
try t.scrollUp(1);
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// First row preserved, rest scrolled
try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str);
}
}
test "Terminal: scrollUp with max_scrollback zero and left/right margin" {
// When max_scrollback is 0 with left/right margins, uses deleteLines path
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAABBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCCDDDDD");
t.carriageReturn();
try t.linefeed();
try t.printString("EEEEEFFFFF");
// Set left/right margins (columns 2-6, 1-indexed = indices 1-5)
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(2, 6);
try t.scrollUp(1);
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// cols 1-5 scroll, col 0 and cols 6+ preserved
try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str);
}
}
test "Terminal: scrollDown simple" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });

View File

@ -100,7 +100,7 @@ pub const Handler = struct {
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_up => try self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.horizontal_tab => try self.horizontalTab(value),
.horizontal_tab_back => try self.horizontalTabBack(value),

View File

@ -22,6 +22,9 @@ const configpkg = @import("../config.zig");
const log = std.log.scoped(.io_exec);
/// Mutex state argument for queueMessage.
pub const MutexState = enum { locked, unlocked };
/// Allocator
alloc: Allocator,
@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void {
pub fn queueMessage(
self: *Termio,
msg: termio.Message,
mutex: enum { locked, unlocked },
mutex: MutexState,
) void {
self.mailbox.send(msg, switch (mutex) {
.locked => self.renderer_state.mutex,

View File

@ -259,8 +259,9 @@ fn setupBash(
resource_dir: []const u8,
env: *EnvMap,
) !?config.Command {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2);
defer args.deinit(alloc);
var stack_fallback = std.heap.stackFallback(4096, alloc);
var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
defer cmd.deinit();
// Iterator that yields each argument in the original command line.
// This will allocate once proportionate to the command line length.
@ -269,9 +270,9 @@ fn setupBash(
// Start accumulating arguments with the executable and initial flags.
if (iter.next()) |exe| {
try args.append(alloc, try alloc.dupeZ(u8, exe));
try cmd.appendArg(exe);
} else return null;
try args.append(alloc, "--posix");
try cmd.appendArg("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
@ -304,17 +305,17 @@ fn setupBash(
if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
return null;
}
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
} else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
while (iter.next()) |remaining_arg| {
try args.append(alloc, try alloc.dupeZ(u8, remaining_arg));
try cmd.appendArg(remaining_arg);
}
break;
} else {
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
}
}
try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
@ -352,8 +353,11 @@ fn setupBash(
);
try env.put("ENV", integ_dir);
// Join the accumulated arguments to form the final command string.
return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) };
// Get the command string from the builder, then copy it to the arena
// allocator. The stackFallback allocator's memory becomes invalid after
// this function returns, so we must copy to the arena.
const cmd_str = try cmd.toOwnedSlice();
return .{ .shell = try alloc.dupeZ(u8, cmd_str) };
}
test "bash" {

View File

@ -246,7 +246,7 @@ pub const StreamHandler = struct {
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_up => try self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.tab_clear_current => self.terminal.tabClear(.current),
.tab_clear_all => self.terminal.tabClear(.all),