Merge branch 'main' into gtk-prompt-tab-title
commit
c418cadf7f
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ zig-cache/
|
|||
.zig-cache/
|
||||
zig-out/
|
||||
/result*
|
||||
/.nixos-test-history
|
||||
example/*.wasm
|
||||
test/ghostty
|
||||
test/cases/**/*.actual.png
|
||||
|
|
|
|||
263
CONTRIBUTING.md
263
CONTRIBUTING.md
|
|
@ -232,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.
|
||||
|
|
|
|||
30
HACKING.md
30
HACKING.md
|
|
@ -93,6 +93,36 @@ produced.
|
|||
> may ask you to fix it and close the issue. It isn't a maintainers job to
|
||||
> review a PR so broken that it requires significant rework to be acceptable.
|
||||
|
||||
## Logging
|
||||
|
||||
Ghostty can write logs to a number of destinations. On all platforms, logging to
|
||||
`stderr` is available. Depending on the platform and how Ghostty was launched,
|
||||
logs sent to `stderr` may be stored by the system and made available for later
|
||||
retrieval.
|
||||
|
||||
On Linux if Ghostty is launched by the default `systemd` user service, you can use
|
||||
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
|
||||
|
||||
On macOS logging to the macOS unified log is available and enabled by default.
|
||||
Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
|
||||
|
||||
Ghostty's logging can be configured in two ways. The first is by what
|
||||
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
|
||||
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
|
||||
any other optimization the debug logs will not be output to `stderr`.
|
||||
|
||||
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
|
||||
to control which destinations receive logs. Ghostty currently defines two
|
||||
destinations:
|
||||
|
||||
- `stderr` - logging to `stderr`.
|
||||
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
|
||||
|
||||
Combine values with a comma to enable multiple destinations. Prefix a
|
||||
destination with `no-` to disable it. Enabling and disabling destinations
|
||||
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
|
||||
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
|
||||
|
||||
## Linting
|
||||
|
||||
### Prettier
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz",
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
.lazy = true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
},
|
||||
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
|
||||
"name": "iterm2_themes",
|
||||
"url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
|
||||
},
|
||||
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ in
|
|||
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
|
||||
path = fetchZigArtifact {
|
||||
name = "iterm2_themes";
|
||||
url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz";
|
||||
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
|
||||
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
|||
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
|
||||
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
|
||||
https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz
|
||||
https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz
|
||||
https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz
|
||||
https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz
|
||||
https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz
|
||||
|
|
@ -33,3 +32,4 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae
|
|||
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
|
||||
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
||||
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
|
||||
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz
|
||||
|
|
|
|||
22
flake.lock
22
flake.lock
|
|
@ -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"
|
||||
|
|
|
|||
22
flake.nix
22
flake.nix
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -803,6 +803,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -99,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()
|
||||
|
|
@ -996,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
|
||||
|
|
@ -1273,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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import Cocoa
|
||||
|
||||
struct QuickTerminalRestorableState: TerminalRestorable {
|
||||
static var version: Int { 1 }
|
||||
|
||||
let focusedSurface: String?
|
||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||
let screenStateEntries: QuickTerminalScreenStateCache.Entries
|
||||
|
||||
init(from controller: QuickTerminalController) {
|
||||
controller.saveScreenState(exitFullscreen: true)
|
||||
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||
self.surfaceTree = controller.surfaceTree
|
||||
self.screenStateEntries = controller.screenStateCache.stateByDisplay
|
||||
}
|
||||
|
||||
init(copy other: QuickTerminalRestorableState) {
|
||||
self = other
|
||||
}
|
||||
|
||||
var baseConfig: Ghostty.SurfaceConfiguration? {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import Cocoa
|
|||
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
|
||||
/// to survive NSScreen garbage collection and automatically prunes stale entries.
|
||||
class QuickTerminalScreenStateCache {
|
||||
typealias Entries = [UUID: DisplayEntry]
|
||||
|
||||
/// The maximum number of saved screen states we retain. This is to avoid some kind of
|
||||
/// pathological memory growth in case we get our screen state serializing wrong. I don't
|
||||
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
|
||||
|
|
@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache {
|
|||
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
||||
|
||||
/// Keyed by display UUID to survive NSScreen garbage collection.
|
||||
private var stateByDisplay: [UUID: DisplayEntry] = [:]
|
||||
|
||||
init() {
|
||||
private(set) var stateByDisplay: Entries = [:]
|
||||
|
||||
init(stateByDisplay: Entries = [:]) {
|
||||
self.stateByDisplay = stateByDisplay
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(onScreensChanged(_:)),
|
||||
|
|
@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
|
|||
}
|
||||
}
|
||||
|
||||
private struct DisplayEntry {
|
||||
struct DisplayEntry: Codable {
|
||||
var frame: NSRect
|
||||
var screenSize: CGSize
|
||||
var scale: CGFloat
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -573,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)
|
||||
|
||||
|
|
@ -1375,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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,19 @@
|
|||
};
|
||||
};
|
||||
|
||||
systemd.user.services = {
|
||||
"org.gnome.Shell@wayland" = {
|
||||
serviceConfig = {
|
||||
ExecStart = [
|
||||
# Clear the list before overriding it.
|
||||
""
|
||||
# Eval API is now internal so Shell needs to run in unsafe mode.
|
||||
"${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.gnomeExtensions.no-overview
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@
|
|||
|
||||
documentation.nixos.enable = false;
|
||||
|
||||
networking.hostName = "ghostty";
|
||||
networking.domain = "mitchellh.com";
|
||||
|
||||
virtualisation.vmVariant = {
|
||||
virtualisation.memorySize = 2048;
|
||||
};
|
||||
|
|
@ -28,17 +25,11 @@
|
|||
users.groups.ghostty = {};
|
||||
|
||||
users.users.ghostty = {
|
||||
isNormalUser = true;
|
||||
description = "Ghostty";
|
||||
group = "ghostty";
|
||||
extraGroups = ["wheel"];
|
||||
isNormalUser = true;
|
||||
initialPassword = "ghostty";
|
||||
};
|
||||
|
||||
environment.etc = {
|
||||
"xdg/autostart/com.mitchellh.ghostty.desktop" = {
|
||||
source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop";
|
||||
};
|
||||
hashedPassword = "";
|
||||
};
|
||||
|
||||
environment.systemPackages = [
|
||||
|
|
@ -61,6 +52,7 @@
|
|||
|
||||
services.displayManager = {
|
||||
autoLogin = {
|
||||
enable = true;
|
||||
user = "ghostty";
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -607,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,
|
||||
|
|
@ -2034,6 +2037,29 @@ 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 });
|
||||
|
||||
std.fs.accessAbsolute(resolved, .{}) catch {
|
||||
self.alloc.free(resolved);
|
||||
return null;
|
||||
};
|
||||
|
||||
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 {
|
||||
|
|
@ -4262,7 +4288,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 => {
|
||||
|
|
@ -5493,6 +5524,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -335,6 +340,7 @@ pub const Action = union(Key) {
|
|||
toggle_quick_terminal,
|
||||
toggle_command_palette,
|
||||
toggle_visibility,
|
||||
toggle_background_opacity,
|
||||
move_tab,
|
||||
goto_tab,
|
||||
goto_split,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -740,6 +742,7 @@ pub const Application = extern struct {
|
|||
.close_all_windows,
|
||||
.float_window,
|
||||
.toggle_visibility,
|
||||
.toggle_background_opacity,
|
||||
.cell_size,
|
||||
.key_sequence,
|
||||
.render_inspector,
|
||||
|
|
@ -2691,7 +2694,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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1654,13 +1654,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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
@ -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`.
|
||||
///
|
||||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,17 @@ pub const Shaper = struct {
|
|||
}
|
||||
};
|
||||
|
||||
const RunOffset = struct {
|
||||
x: f64 = 0,
|
||||
y: f64 = 0,
|
||||
};
|
||||
|
||||
const CellOffset = struct {
|
||||
cluster: u32 = 0,
|
||||
x: f64 = 0,
|
||||
y: f64 = 0,
|
||||
};
|
||||
|
||||
/// Create a CoreFoundation Dictionary suitable for
|
||||
/// settings the font features of a CoreText font.
|
||||
fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
|
||||
|
|
@ -377,12 +388,15 @@ pub const Shaper = struct {
|
|||
const line = typesetter.createLine(.{ .location = 0, .length = 0 });
|
||||
self.cf_release_pool.appendAssumeCapacity(line);
|
||||
|
||||
// This keeps track of the current offsets within a single cell.
|
||||
var cell_offset: struct {
|
||||
cluster: u32 = 0,
|
||||
x: f64 = 0,
|
||||
y: f64 = 0,
|
||||
} = .{};
|
||||
// This keeps track of the current offsets within a run.
|
||||
var run_offset: RunOffset = .{};
|
||||
|
||||
// This keeps track of the current offsets within a cell.
|
||||
var cell_offset: CellOffset = .{};
|
||||
|
||||
// For debugging positions, turn this on:
|
||||
//var start_index: usize = 0;
|
||||
//var end_index: usize = 0;
|
||||
|
||||
// Clear our cell buf and make sure we have enough room for the whole
|
||||
// line of glyphs, so that we can just assume capacity when appending
|
||||
|
|
@ -411,15 +425,18 @@ pub const Shaper = struct {
|
|||
// Get our glyphs and positions
|
||||
const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc);
|
||||
const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc);
|
||||
const positions = ctrun.getPositionsPtr() orelse try ctrun.getPositions(alloc);
|
||||
const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc);
|
||||
assert(glyphs.len == advances.len);
|
||||
assert(glyphs.len == positions.len);
|
||||
assert(glyphs.len == indices.len);
|
||||
|
||||
for (
|
||||
glyphs,
|
||||
advances,
|
||||
positions,
|
||||
indices,
|
||||
) |glyph, advance, index| {
|
||||
) |glyph, advance, position, index| {
|
||||
// Our cluster is also our cell X position. If the cluster changes
|
||||
// then we need to reset our current cell offsets.
|
||||
const cluster = state.codepoints.items[index].cluster;
|
||||
|
|
@ -431,20 +448,41 @@ pub const Shaper = struct {
|
|||
// wait for that.
|
||||
if (cell_offset.cluster > cluster) break :pad;
|
||||
|
||||
cell_offset = .{ .cluster = cluster };
|
||||
cell_offset = .{
|
||||
.cluster = cluster,
|
||||
.x = run_offset.x,
|
||||
.y = run_offset.y,
|
||||
};
|
||||
|
||||
// For debugging positions, turn this on:
|
||||
// start_index = index;
|
||||
// end_index = index;
|
||||
//} else {
|
||||
// if (index < start_index) {
|
||||
// start_index = index;
|
||||
// }
|
||||
// if (index > end_index) {
|
||||
// end_index = index;
|
||||
// }
|
||||
}
|
||||
|
||||
// For debugging positions, turn this on:
|
||||
//try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index);
|
||||
|
||||
const x_offset = position.x - cell_offset.x;
|
||||
const y_offset = position.y - cell_offset.y;
|
||||
|
||||
self.cell_buf.appendAssumeCapacity(.{
|
||||
.x = @intCast(cluster),
|
||||
.x_offset = @intFromFloat(@round(cell_offset.x)),
|
||||
.y_offset = @intFromFloat(@round(cell_offset.y)),
|
||||
.x_offset = @intFromFloat(@round(x_offset)),
|
||||
.y_offset = @intFromFloat(@round(y_offset)),
|
||||
.glyph_index = glyph,
|
||||
});
|
||||
|
||||
// Add our advances to keep track of our current cell offsets.
|
||||
// Add our advances to keep track of our run offsets.
|
||||
// Advances apply to the NEXT cell.
|
||||
cell_offset.x += advance.width;
|
||||
cell_offset.y += advance.height;
|
||||
run_offset.x += advance.width;
|
||||
run_offset.y += advance.height;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -613,6 +651,63 @@ pub const Shaper = struct {
|
|||
_ = self;
|
||||
}
|
||||
};
|
||||
|
||||
fn debugPositions(
|
||||
self: *Shaper,
|
||||
alloc: Allocator,
|
||||
run_offset: RunOffset,
|
||||
cell_offset: CellOffset,
|
||||
position: macos.graphics.Point,
|
||||
start_index: usize,
|
||||
end_index: usize,
|
||||
index: usize,
|
||||
) !void {
|
||||
const state = &self.run_state;
|
||||
const x_offset = position.x - cell_offset.x;
|
||||
const y_offset = position.y - cell_offset.y;
|
||||
const advance_x_offset = run_offset.x - cell_offset.x;
|
||||
const advance_y_offset = run_offset.y - cell_offset.y;
|
||||
const x_offset_diff = x_offset - advance_x_offset;
|
||||
const y_offset_diff = y_offset - advance_y_offset;
|
||||
|
||||
if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) {
|
||||
var allocating = std.Io.Writer.Allocating.init(alloc);
|
||||
const writer = &allocating.writer;
|
||||
const codepoints = state.codepoints.items[start_index .. end_index + 1];
|
||||
for (codepoints) |cp| {
|
||||
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
|
||||
try writer.print("\\u{{{x}}}", .{cp.codepoint});
|
||||
}
|
||||
try writer.writeAll(" → ");
|
||||
for (codepoints) |cp| {
|
||||
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
|
||||
try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))});
|
||||
}
|
||||
const formatted_cps = try allocating.toOwnedSlice();
|
||||
|
||||
// Note that the codepoints from `start_index .. end_index + 1`
|
||||
// might not include all the codepoints being shaped. Sometimes a
|
||||
// codepoint gets represented in a glyph with a later codepoint
|
||||
// such that the index for the former codepoint is skipped and just
|
||||
// the index for the latter codepoint is used. Additionally, this
|
||||
// gets called as we iterate through the glyphs, so it won't
|
||||
// include the codepoints that come later that might be affecting
|
||||
// positions for the current glyph. Usually though, for that case
|
||||
// the positions of the later glyphs will also be affected and show
|
||||
// up in the logs.
|
||||
log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{
|
||||
cell_offset.cluster,
|
||||
x_offset,
|
||||
y_offset,
|
||||
advance_x_offset,
|
||||
advance_y_offset,
|
||||
x_offset_diff,
|
||||
y_offset_diff,
|
||||
state.codepoints.items[index].codepoint,
|
||||
formatted_cps,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "run iterator" {
|
||||
|
|
@ -1268,7 +1363,7 @@ test "shape with empty cells in between" {
|
|||
}
|
||||
}
|
||||
|
||||
test "shape Chinese characters" {
|
||||
test "shape Combining characters" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
|
|
@ -1286,6 +1381,9 @@ test "shape Chinese characters" {
|
|||
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Enable grapheme clustering
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
try s.nextSlice(buf[0..buf_idx]);
|
||||
|
|
@ -1333,6 +1431,9 @@ test "shape Devanagari string" {
|
|||
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Disable grapheme clustering
|
||||
t.modes.set(.grapheme_cluster, false);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
try s.nextSlice("अपार्टमेंट");
|
||||
|
|
@ -1365,6 +1466,62 @@ test "shape Devanagari string" {
|
|||
try testing.expect(try it.next(alloc) == null);
|
||||
}
|
||||
|
||||
test "shape Tai Tham vowels (position differs from advance)" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// We need a font that supports Tai Tham for this to work, if we can't find
|
||||
// Noto Sans Tai Tham, which is a system font on macOS, we just skip the
|
||||
// test.
|
||||
var testdata = testShaperWithDiscoveredFont(
|
||||
alloc,
|
||||
"Noto Sans Tai Tham",
|
||||
) catch return error.SkipZigTest;
|
||||
defer testdata.deinit();
|
||||
|
||||
var buf: [32]u8 = undefined;
|
||||
var buf_idx: usize = 0;
|
||||
buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ
|
||||
buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ
|
||||
|
||||
// Make a screen with some data
|
||||
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Enable grapheme clustering
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
try s.nextSlice(buf[0..buf_idx]);
|
||||
|
||||
var state: terminal.RenderState = .empty;
|
||||
defer state.deinit(alloc);
|
||||
try state.update(alloc, &t);
|
||||
|
||||
// Get our run iterator
|
||||
var shaper = &testdata.shaper;
|
||||
var it = shaper.runIterator(.{
|
||||
.grid = testdata.grid,
|
||||
.cells = state.row_data.get(0).cells.slice(),
|
||||
});
|
||||
var count: usize = 0;
|
||||
while (try it.next(alloc)) |run| {
|
||||
count += 1;
|
||||
|
||||
const cells = try shaper.shape(run);
|
||||
const cell_width = run.grid.metrics.cell_width;
|
||||
try testing.expectEqual(@as(usize, 2), cells.len);
|
||||
try testing.expectEqual(@as(u16, 0), cells[0].x);
|
||||
try testing.expectEqual(@as(u16, 0), cells[1].x);
|
||||
|
||||
// The first glyph renders in the next cell
|
||||
try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset);
|
||||
try testing.expectEqual(@as(i16, 0), cells[1].x_offset);
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 1), count);
|
||||
}
|
||||
|
||||
test "shape box glyphs" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -755,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.
|
||||
|
|
@ -1240,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,
|
||||
|
|
|
|||
|
|
@ -618,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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=$?
|
||||
|
|
|
|||
|
|
@ -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)});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -8893,7 +9058,7 @@ test "Terminal: insertBlanks shift graphemes" {
|
|||
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Disable grapheme clustering
|
||||
// Enable grapheme clustering
|
||||
t.modes.set(.grapheme_cluster, true);
|
||||
|
||||
try t.printString("A");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ DECID = "DECID"
|
|||
flate = "flate"
|
||||
typ = "typ"
|
||||
kend = "kend"
|
||||
# Tai Tham is a script/writing system
|
||||
Tham = "Tham"
|
||||
# GTK
|
||||
GIR = "GIR"
|
||||
# terminfo
|
||||
|
|
|
|||
Loading…
Reference in New Issue