diff --git a/.gitattributes b/.gitattributes
index eef19e09b9..1485158739 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,4 @@
+build.zig.zon.nix linguist-generated=true
vendor/** linguist-vendored
website/** linguist-documentation
pkg/breakpad/vendor/** linguist-vendored
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 3339ee71c0..ec55f2dffd 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -50,5 +50,5 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
useDaemon: false # sometimes fails on short jobs
- - name: Check Zig cache hash
- run: nix develop -c ./nix/build-support/check-zig-cache-hash.sh
+ - name: Check Zig cache
+ run: nix develop -c ./nix/build-support/check-zig-cache.sh
diff --git a/.github/workflows/publish-tag.yml b/.github/workflows/publish-tag.yml
new file mode 100644
index 0000000000..4589821400
--- /dev/null
+++ b/.github/workflows/publish-tag.yml
@@ -0,0 +1,74 @@
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to deploy (format: vX.Y.Z)"
+ required: true
+
+name: Publish Tagged Release
+
+# We must only run one release workflow at a time to prevent corrupting
+# our release artifacts.
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
+jobs:
+ setup:
+ runs-on: namespace-profile-ghostty-sm
+ outputs:
+ version: ${{ steps.extract_version.outputs.version }}
+ steps:
+ - name: Validate Version Input
+ run: |
+ if [[ ! "${{ github.event.inputs.version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Error: Version must follow the format vX.Y.Z (e.g., v1.0.0)."
+ exit 1
+ fi
+
+ echo "Version is valid: ${{ github.event.inputs.version }}"
+
+ - name: Exract the Version
+ id: extract_version
+ run: |
+ VERSION=${{ github.event.inputs.version }}
+ VERSION=${VERSION#v}
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+
+ upload:
+ needs: [setup]
+ runs-on: namespace-profile-ghostty-sm
+ env:
+ GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
+ steps:
+ - name: Validate Release Files
+ run: |
+ BASE="https://release.files.ghostty.org/${GHOSTTY_VERSION}"
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-${GHOSTTY_VERSION}.tar.gz" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-source.tar.gz" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-source.tar.gz.minisig" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-macos-universal.zip" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/ghostty-macos-universal-dsym.zip" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/Ghostty.dmg" | grep -q "^200$" || exit 1
+ curl -I -s -o /dev/null -w "%{http_code}" "${BASE}/appcast-staged.xml" | grep -q "^200$" || exit 1
+
+ - name: Download Staged Appcast
+ run: |
+ curl -L https://release.files.ghostty.org/${GHOSTTY_VERSION}/appcast-staged.xml > appcast-staged.xml
+ mv appcast-staged.xml appcast.xml
+
+ - name: Upload Appcast
+ run: |
+ rm -rf blob
+ mkdir blob
+ mv appcast.xml blob/appcast.xml
+ - name: Upload Appcast to R2
+ uses: ryand56/r2-upload-action@latest
+ with:
+ r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
+ r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}
+ r2-secret-access-key: ${{ secrets.CF_R2_RELEASE_SECRET_KEY }}
+ r2-bucket: ghostty-release
+ source-dir: blob
+ destination-dir: ./
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index cf94bf23ef..0767152f58 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -7,6 +7,7 @@ on:
upload:
description: "Upload final artifacts to R2"
default: false
+
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
@@ -367,6 +368,7 @@ jobs:
mv ghostty-macos-universal.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal.zip
mv ghostty-macos-universal-dsym.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal-dsym.zip
mv Ghostty.dmg blob/${GHOSTTY_VERSION}/Ghostty.dmg
+ mv appcast.xml blob/${GHOSTTY_VERSION}/appcast-staged.xml
- name: Upload to R2
uses: ryand56/r2-upload-action@latest
with:
@@ -376,18 +378,3 @@ jobs:
r2-bucket: ghostty-release
source-dir: blob
destination-dir: ./
-
- - name: Prep Appcast
- run: |
- rm -rf blob
- mkdir blob
- mv appcast.xml blob/appcast.xml
- - name: Upload Appcast to R2
- uses: ryand56/r2-upload-action@latest
- with:
- r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
- r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}
- r2-secret-access-key: ${{ secrets.CF_R2_RELEASE_SECRET_KEY }}
- r2-bucket: ghostty-release
- source-dir: blob
- destination-dir: ./
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0f32162a95..2082d72868 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -202,10 +202,14 @@ jobs:
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: get the Zig deps
+ id: deps
+ run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
+
# GhosttyKit is the framework that is built from Zig for our native
# Mac app to access.
- name: Build GhosttyKit
- run: nix develop -c zig build
+ run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
# The native app is built with native XCode tooling. This also does
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
@@ -238,35 +242,39 @@ jobs:
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: get the Zig deps
+ id: deps
+ run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
+
- name: Test All
run: |
# OpenGL
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=freetype
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_freetype
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_harfbuzz
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_noshape
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=freetype
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_freetype
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_harfbuzz
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_noshape
# Metal
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=freetype
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_freetype
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_harfbuzz
- nix develop -c zig build test -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_noshape
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=freetype
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_freetype
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_harfbuzz
+ nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_noshape
- name: Build All
run: |
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=freetype
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_freetype
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_harfbuzz
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_noshape
-
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=freetype
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_freetype
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_harfbuzz
- nix develop -c zig build -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_noshape
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=freetype
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_freetype
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_harfbuzz
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=opengl -Dfont-backend=coretext_noshape
+
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=freetype
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_freetype
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_harfbuzz
+ nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Dapp-runtime=glfw -Drenderer=metal -Dfont-backend=coretext_noshape
build-windows:
runs-on: windows-2022
@@ -471,8 +479,12 @@ jobs:
- name: XCode Select
run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: get the Zig deps
+ id: deps
+ run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
+
- name: test
- run: nix develop -c zig build test
+ run: nix develop -c zig build test --system ${{ steps.deps.outputs.deps }}
prettier:
if: github.repository == 'ghostty-org/ghostty'
diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml
index 569ef6765e..8a6c77ea5f 100644
--- a/.github/workflows/update-colorschemes.yml
+++ b/.github/workflows/update-colorschemes.yml
@@ -48,14 +48,14 @@ jobs:
run: |
# Only proceed if build.zig.zon has changed
if ! git diff --exit-code build.zig.zon; then
- nix develop -c ./nix/build-support/check-zig-cache-hash.sh --update
- nix develop -c ./nix/build-support/check-zig-cache-hash.sh
+ nix develop -c ./nix/build-support/check-zig-cache.sh --update
+ nix develop -c ./nix/build-support/check-zig-cache.sh
fi
# Verify the build still works. We choose an arbitrary build type
# as a canary instead of testing all build types.
- name: Test Build
- run: nix develop -c zig build -Dapp-runtime=gtk -Dgtk-adwaita=true
+ run: nix build .#ghostty
- name: Create pull request
uses: peter-evans/create-pull-request@v7
@@ -66,7 +66,7 @@ jobs:
commit-message: "deps: Update iTerm2 color schemes"
add-paths: |
build.zig.zon
- nix/zigCacheHash.nix
+ build.zig.zon.nix
body: |
Upstream revision: https://github.com/mbadolato/iTerm2-Color-Schemes/tree/${{ steps.zig_fetch.outputs.upstream_rev }}
labels: dependencies
diff --git a/.gitignore b/.gitignore
index 0e301f8c41..b37c80ebe8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,5 @@ test/cases/**/*.actual.png
glad.zip
/Box_test.ppm
/Box_test_diff.ppm
+/ghostty.qcow2
+/build.zig.zon2json-lock
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000000..835244ebc5
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1,149 @@
+# This file documents the subsystem maintainers of the Ghostty project
+# along with the responsibilities of a maintainer and how one can become
+# a maintainer.
+#
+# Ghostty follows a subsystem maintainer model where distinguished
+# contributors (with mutual agreement) are designated as maintainers of a
+# specific subset of the project. A subsystem maintainer has more privileges
+# and authority over a specific part of the project than a regular
+# contributor and deference is given to them when making decisions about
+# their subsystem.
+#
+# Ultimately Ghostty has a BDFL (Benevolent Dictator For Life) model
+# currently with @mitchellh as the BDFL. The BDFL has the final say in all
+# decisions and may override a maintainer's decision if necessary. I like to
+# say its a BDFLFN (Benevolent Dictator For Life "For Now") model because
+# long term I'd like to see the project be more community driven. But for
+# now, early in its life, we're going with this model.
+#
+# ## Privileges
+#
+# - Authority to approve or reject pull requests in their subsystem.
+# - Authority to moderate issues and discussions in their subsystem.
+# - Authority to make roadmap and design decisions about their subsystem
+# with input only from other subsystem maintainers.
+#
+# In all scenarios, the BDFL doesn't need to be consulted for decisions
+# but may revert or override decisions if necessary. The expectation is
+# that maintainers will be trusted to make the right decisions for their
+# subsystem and this will be rare.
+#
+# ## Responsibilities
+#
+# Subsystem maintainership is a voluntary role and maintainers are not
+# expected to dedicate any amount of time to the project. However, if a
+# maintainer is inactive for a long period of time, they may be removed from
+# the maintainers list to avoid bitrot or outdated information.
+#
+# Maintainers are expected to be exemplary members of the community and
+# should be respectful, helpful, and professional in all interactions.
+# This is both in regards to the community at large as well as other
+# subsystem maintainers as well as @mitchellh.
+#
+# As technical leaders, maintainers are expected to be mindful about
+# breaking changes, performance, user impact, and other technical
+# considerations in their subsystem. They should be considerate of large
+# changes and should be able to justify their decisions.
+#
+# Notably, maintainers have NO OBLIGATION to review pull requests or issues
+# in their subsystem. They have full discretion to review or not review
+# anything they want. This isn't a job! It is a role of trust and authority
+# and the expectation is that maintainers will use their best judgement.
+#
+# ## Becoming a Maintainer
+#
+# Maintainer candidates are noticed and proposed by the community. Anyone
+# may propose themselves or someone else as a maintainer. The BDFL along
+# with existing maintainers will discuss and decide.
+#
+# Generally, we want to see consistent high quality contributions to a
+# specific subsystem before considering someone as a maintainer. There isn't
+# an exact number of contributions or time period required but generally
+# we're looking for an order of a dozen or more contributions over a period of
+# months, at least.
+#
+# # Subsystem List
+#
+# The subsystems don't fully cover the entirety of the Ghostty project but
+# are created organically as experts in certain areas emerge. If you feel
+# you are an expert in a certain area and would like to be a maintainer,
+# please reach out to @mitchellh on Discord.
+#
+# (Alphabetical order)
+#
+# - @ghostty-org/font - All things font related including discovery,
+# rasterization, shaping, coloring, etc.
+#
+# - @ghostty-org/gtk - Anything GTK-related in the project, primarily
+# the GTK apprt. Also includes X11/Wayland integrations and general
+# Linux support.
+#
+# - @ghostty-org/macos - The Ghostty macOS app and any macOS-specific
+# features, configurations, etc.
+#
+# - @ghostty-org/renderer - Ghostty rendering subsystem, including the
+# rendering abstractions as well as specific renderers like OpenGL
+# and Metal.
+#
+# - @ghostty-org/shell - Ghostty shell integration, including shell
+# completions, shell detection, and any other shell interactions.
+#
+# - @ghostty-org/terminal - The terminal emulator subsystem, including
+# subprocess management and pty handling, escape sequence parsing,
+# key encoding, etc.
+#
+# ## Outside of Ghostty
+#
+# Other "subsystems" exist outside of Ghostty and will not be represented
+# in this CODEOWNERS file:
+#
+# - @ghostty-org/discord-bot - Maintainers of the Ghostty Discord bot.
+#
+# - @ghostty-org/website - Maintainers of the Ghostty website.
+
+# Font
+/src/font/ @ghostty-org/font
+/pkg/fontconfig/ @ghostty-org/font
+/pkg/freetype/ @ghostty-org/font
+/pkg/harfbuzz/ @ghostty-org/font
+
+# GTK
+/src/apprt/gtk/ @ghostty-org/gtk
+/src/os/cgroup.zig @ghostty-org/gtk
+/src/os/flatpak.zig @ghostty-org/gtk
+/dist/linux/ @ghostty-org/gtk
+
+# macOS
+#
+# This includes libghostty because the macOS apprt is built on top of
+# libghostty and often requires or is impacted by changes to libghostty.
+# macOS subsystem maintainers are expected to only work on libghostty
+# insofar as it impacts the macOS apprt.
+/include/ghostty.h @ghostty-org/macos
+/src/apprt/embedded.zig @ghostty-org/macos
+/src/os/cf_release_thread.zig @ghostty-org/macos
+/src/os/macos.zig @ghostty-org/macos
+/macos/ @ghostty-org/macos
+/dist/macos/ @ghostty-org/macos
+/pkg/apple-sdk/ @ghostty-org/macos
+/pkg/macos/ @ghostty-org/macos
+
+# Renderer
+/src/renderer.zig @ghostty-org/renderer
+/src/renderer/ @ghostty-org/renderer
+/pkg/glslang/ @ghostty-org/renderer
+/pkg/opengl/ @ghostty-org/renderer
+/pkg/spirv-cross/ @ghostty-org/renderer
+/pkg/wuffs/ @ghostty-org/renderer
+
+# Shell
+/src/shell-integration/ @ghostty-org/shell
+/src/termio/shell-integration.zig @ghostty-org/shell
+
+# Terminal
+/src/simd/ @ghostty-org/terminal
+/src/terminal/ @ghostty-org/terminal
+/src/terminfo/ @ghostty-org/terminal
+/src/unicode/ @ghostty-org/terminal
+/src/Surface.zig @ghostty-org/terminal
+/src/surface_mouse.zig @ghostty-org/terminal
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index af3c30be7d..e4d148df87 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -77,3 +77,183 @@ 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.
+
+## 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#`.
+
+### Linux
+
+1. Check out the Ghostty source and change to the directory.
+2. Run `nix run .#`. `` 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. The 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#`.
+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.
diff --git a/build.zig b/build.zig
index 38d2bca6dd..238963596c 100644
--- a/build.zig
+++ b/build.zig
@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
-const buildpkg = @import("src/build/main.zig");
+pub const buildpkg = @import("src/build/main.zig");
comptime {
buildpkg.requireZig("0.13.0");
@@ -14,6 +14,7 @@ pub fn build(b: *std.Build) !void {
// Ghostty dependencies used by many artifacts.
const deps = try buildpkg.SharedDeps.init(b, &config);
+ const module_deps = try buildpkg.ModuleDeps.init(b, &config);
const exe = try buildpkg.GhosttyExe.init(b, &config, &deps);
if (config.emit_helpgen) deps.help_strings.install();
@@ -41,6 +42,13 @@ pub fn build(b: *std.Build) !void {
resources.install();
}
+ const ghostty = b.addModule("ghostty", .{
+ .root_source_file = b.path("src/ghostty.zig"),
+ .target = config.target,
+ .optimize = config.optimize,
+ });
+ _ = try module_deps.add(ghostty);
+
// Libghostty
//
// Note: libghostty is not stable for general purpose use. It is used
diff --git a/build.zig.zon b/build.zig.zon
index 4b9a3856b2..9a4772c53c 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,12 +1,12 @@
.{
.name = "ghostty",
- .version = "1.0.2",
+ .version = "1.1.1",
.paths = .{""},
.dependencies = .{
// Zig libs
.libxev = .{
- .url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz",
- .hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf",
+ .url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz",
+ .hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c",
},
.mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
@@ -41,6 +41,10 @@
.url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd",
.hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8",
},
+ .gobject = .{
+ .url = "https://github.com/ianprime0509/zig-gobject/releases/download/v0.2.2/bindings-gnome47.tar.zst",
+ .hash = "12208d70ee791d7ef7e16e1c3c9c1127b57f1ed066a24f87d57fc9f730c5dc394b9d",
+ },
// C libs
.cimgui = .{ .path = "./pkg/cimgui" },
@@ -79,8 +83,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
- .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/25cb3c3f52c7011cd8a599f8d144fc63f4409eb6.tar.gz",
- .hash = "1220dc1096bda9721c1f5256177539bf37b41ac6fb70d58eadf0eec45359676382e5",
+ .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/db227d159adc265818f2e898da0f70ef8d7b580e.tar.gz",
+ .hash = "12203d2647e5daf36a9c85b969e03f422540786ce9ea624eb4c26d204fe1f46218f3",
},
},
}
diff --git a/build.zig.zon.nix b/build.zig.zon.nix
new file mode 100644
index 0000000000..64cb8f369d
--- /dev/null
+++ b/build.zig.zon.nix
@@ -0,0 +1,390 @@
+# generated by zon2nix (https://github.com/Cloudef/zig2nix)
+{
+ lib,
+ linkFarm,
+ fetchurl,
+ fetchgit,
+ runCommandLocal,
+ zig,
+ name ? "zig-packages",
+}:
+with builtins;
+with lib; let
+ unpackZigArtifact = {
+ name,
+ artifact,
+ }:
+ runCommandLocal name
+ {
+ nativeBuildInputs = [zig];
+ }
+ ''
+ hash="$(zig fetch --global-cache-dir "$TMPDIR" ${artifact})"
+ mv "$TMPDIR/p/$hash" "$out"
+ chmod 755 "$out"
+ '';
+
+ fetchZig = {
+ name,
+ url,
+ hash,
+ }: let
+ artifact = fetchurl {inherit url hash;};
+ in
+ unpackZigArtifact {inherit name artifact;};
+
+ fetchGitZig = {
+ name,
+ url,
+ hash,
+ }: let
+ parts = splitString "#" url;
+ url_base = elemAt parts 0;
+ url_without_query = elemAt (splitString "?" url_base) 0;
+ rev_base = elemAt parts 1;
+ rev =
+ if match "^[a-fA-F0-9]{40}$" rev_base != null
+ then rev_base
+ else "refs/heads/${rev_base}";
+ in
+ fetchgit {
+ inherit name rev hash;
+ url = url_without_query;
+ deepClone = false;
+ };
+
+ fetchZigArtifact = {
+ name,
+ url,
+ hash,
+ }: let
+ parts = splitString "://" url;
+ proto = elemAt parts 0;
+ path = elemAt parts 1;
+ fetcher = {
+ "git+http" = fetchGitZig {
+ inherit name hash;
+ url = "http://${path}";
+ };
+ "git+https" = fetchGitZig {
+ inherit name hash;
+ url = "https://${path}";
+ };
+ http = fetchZig {
+ inherit name hash;
+ url = "http://${path}";
+ };
+ https = fetchZig {
+ inherit name hash;
+ url = "https://${path}";
+ };
+ };
+ in
+ fetcher.${proto};
+in
+ linkFarm name [
+ {
+ name = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c";
+ path = fetchZigArtifact {
+ name = "libxev";
+ url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz";
+ hash = "sha256-VHP90NTytIZ8UZsYRKOOxN490/I6yv6ec40sP8y5MJ8=";
+ };
+ }
+ {
+ name = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62";
+ path = fetchZigArtifact {
+ name = "mach_glfw";
+ url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz";
+ hash = "sha256-HhXIvWUS8/CHWY4VXPG2ZEo+we8XOn3o5rYJCQ1n8Nk=";
+ };
+ }
+ {
+ name = "1220736fa4ba211162c7a0e46cc8fe04d95921927688bff64ab5da7420d098a7272d";
+ path = fetchZigArtifact {
+ name = "glfw";
+ url = "https://github.com/mitchellh/glfw/archive/b552c6ec47326b94015feddb36058ea567b87159.tar.gz";
+ hash = "sha256-IeBVAOQmtyFqVxzuXPek1onuPwIamcOyYtxqKpPEQjU=";
+ };
+ }
+ {
+ name = "12202adbfecdad671d585c9a5bfcbd5cdf821726779430047742ce1bf94ad67d19cb";
+ path = fetchZigArtifact {
+ name = "xcode_frameworks";
+ url = "https://github.com/mitchellh/xcode-frameworks/archive/69801c154c39d7ae6129ea1ba8fe1afe00585fc8.tar.gz";
+ hash = "sha256-mP/I2coL57UJm/3+4Q8sPAgQwk8V4zM+S4VBBTrX2To=";
+ };
+ }
+ {
+ name = "122004bfd4c519dadfb8e6281a42fc34fd1aa15aea654ea8a492839046f9894fa2cf";
+ path = fetchZigArtifact {
+ name = "vulkan_headers";
+ url = "https://github.com/mitchellh/vulkan-headers/archive/04c8a0389d5a0236a96312988017cd4ce27d8041.tar.gz";
+ hash = "sha256-K+zrRudgHFukOM6En1StRYRMNYkeRk+qHTXvrXaG+FU=";
+ };
+ }
+ {
+ name = "1220b3164434d2ec9db146a40bf3a30f490590d68fa8529776a3138074f0da2c11ca";
+ path = fetchZigArtifact {
+ name = "wayland_headers";
+ url = "https://github.com/mitchellh/wayland-headers/archive/5f991515a29f994d87b908115a2ab0b899474bd1.tar.gz";
+ hash = "sha256-uFilLZinKkZt6RdVTV3lUmJpzpswDdFva22FvwU/XQI=";
+ };
+ }
+ {
+ name = "122089c326186c84aa2fd034b16abc38f3ebf4862d9ae106dc1847ac44f557b36465";
+ path = fetchZigArtifact {
+ name = "x11_headers";
+ url = "https://github.com/mitchellh/x11-headers/archive/2ffbd62d82ff73ec929dd8de802bc95effa0ef88.tar.gz";
+ hash = "sha256-EhV2bmTY/OMYN1wEul35gD0hQgS/Al262jO3pVr0O+c=";
+ };
+ }
+ {
+ name = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f";
+ path = fetchZigArtifact {
+ name = "vaxis";
+ url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b";
+ hash = "sha256-fFf79fCy4QQFVNcN722tSMjB6FyVEzCB36oH1olk9JQ=";
+ };
+ }
+ {
+ name = "1220dd654ef941fc76fd96f9ec6adadf83f69b9887a0d3f4ee5ac0a1a3e11be35cf5";
+ path = fetchZigArtifact {
+ name = "zigimg";
+ url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e";
+ hash = "sha256-oLf3YH3yeg4ikVO/GahMCDRMTU31AHkfSnF4rt7xTKo=";
+ };
+ }
+ {
+ name = "122055beff332830a391e9895c044d33b15ea21063779557024b46169fb1984c6e40";
+ path = fetchZigArtifact {
+ name = "zg";
+ url = "https://codeberg.org/atman/zg/archive/v0.13.2.tar.gz";
+ hash = "sha256-2x9hT7bYq9KJYWLVOf21a+QvTG/F7HWT+YK15IMRzNY=";
+ };
+ }
+ {
+ name = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a";
+ path = fetchZigArtifact {
+ name = "z2d";
+ url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a";
+ hash = "sha256-YpWXn1J3JKQSCrWB25mAfzd1/T56QstEZnhPzBwxgoM=";
+ };
+ }
+ {
+ name = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634";
+ path = fetchZigArtifact {
+ name = "zig_objc";
+ url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz";
+ hash = "sha256-H+HIbh2T23uzrsg9/1/vl9Ir1HCAa2pzeTx6zktJH9Q=";
+ };
+ }
+ {
+ name = "12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc";
+ path = fetchZigArtifact {
+ name = "zig_js";
+ url = "https://github.com/mitchellh/zig-js/archive/d0b8b0a57c52fbc89f9d9fecba75ca29da7dd7d1.tar.gz";
+ hash = "sha256-fyNeCVbC9UAaKJY6JhAZlT0A479M/AKYMPIWEZbDWD0=";
+ };
+ }
+ {
+ name = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25";
+ path = fetchZigArtifact {
+ name = "ziglyph";
+ url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz";
+ hash = "sha256-cse98+Ft8QUjX+P88yyYfaxJOJGQ9M7Ymw7jFxDz89k=";
+ };
+ }
+ {
+ name = "12209ca054cb1919fa276e328967f10b253f7537c4136eb48f3332b0f7cf661cad38";
+ path = fetchZigArtifact {
+ name = "zig_wayland";
+ url = "https://codeberg.org/ifreund/zig-wayland/archive/fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz";
+ hash = "sha256-RtAystqK/GRYIquTK1KfD7rRSCrfuzAvCD1Z9DE1ldc=";
+ };
+ }
+ {
+ name = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8";
+ path = fetchZigArtifact {
+ name = "zf";
+ url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd";
+ hash = "sha256-t6QNrEJZ4GZZsYixjYvpdrYoCmNbG8TTUmGs2MFa4sU=";
+ };
+ }
+ {
+ name = "1220c72c1697dd9008461ead702997a15d8a1c5810247f02e7983b9f74c6c6e4c087";
+ path = fetchZigArtifact {
+ name = "vaxis";
+ url = "git+https://github.com/rockorager/libvaxis/?ref=main#dc0a228a5544988d4a920cfb40be9cd28db41423";
+ hash = "sha256-QWN4jOrA91KlbqmeEHHJ4HTnCC9nmfxt8DHUXJpAzLI=";
+ };
+ }
+ {
+ name = "12208d70ee791d7ef7e16e1c3c9c1127b57f1ed066a24f87d57fc9f730c5dc394b9d";
+ path = fetchZigArtifact {
+ name = "gobject";
+ url = "https://github.com/ianprime0509/zig-gobject/releases/download/v0.2.2/bindings-gnome47.tar.zst";
+ hash = "sha256-UU97kNv/bZzQPKz1djhEDLapLguvfBpFfWVb6FthtcI=";
+ };
+ }
+ {
+ name = "12202cdac858abc52413a6c6711d5026d2d3c8e13f95ca2c327eade0736298bb021f";
+ path = fetchZigArtifact {
+ name = "wayland";
+ url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz";
+ hash = "sha256-m9G72jdG30KH2yQhNBcBJ46OnekzuX0i2uIOyczkpLk=";
+ };
+ }
+ {
+ name = "12201a57c6ce0001aa034fa80fba3e1cd2253c560a45748f4f4dd21ff23b491cddef";
+ path = fetchZigArtifact {
+ name = "wayland_protocols";
+ url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz";
+ hash = "sha256-XO3K3egbdeYPI+XoO13SuOtO+5+Peb16NH0UiusFMPg=";
+ };
+ }
+ {
+ name = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566";
+ path = fetchZigArtifact {
+ name = "plasma_wayland_protocols";
+ url = "git+https://github.com/KDE/plasma-wayland-protocols?ref=main#db525e8f9da548cffa2ac77618dd0fbe7f511b86";
+ hash = "sha256-iWRv3+OfmHxmeCJ8m0ChjgZX6mwXlcFmK8P/Vt8gDj4=";
+ };
+ }
+ {
+ name = "12203d2647e5daf36a9c85b969e03f422540786ce9ea624eb4c26d204fe1f46218f3";
+ path = fetchZigArtifact {
+ name = "iterm2_themes";
+ url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/db227d159adc265818f2e898da0f70ef8d7b580e.tar.gz";
+ hash = "sha256-Iyf7U4rpvNkPX4AOEbYSYGte5+SjRwsWD2luOn1Hz8U=";
+ };
+ }
+ {
+ name = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402";
+ path = fetchZigArtifact {
+ name = "imgui";
+ url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz";
+ hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=";
+ };
+ }
+ {
+ name = "1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d";
+ path = fetchZigArtifact {
+ name = "freetype";
+ url = "https://github.com/freetype/freetype/archive/refs/tags/VER-2-13-2.tar.gz";
+ hash = "sha256-QnIB9dUVFnDQXB9bRb713aHy592XHvVPD+qqf/0quQw=";
+ };
+ }
+ {
+ name = "1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66";
+ path = fetchZigArtifact {
+ name = "libpng";
+ url = "https://github.com/glennrp/libpng/archive/refs/tags/v1.6.43.tar.gz";
+ hash = "sha256-/syVtGzwXo4/yKQUdQ4LparQDYnp/fF16U/wQcrxoDo=";
+ };
+ }
+ {
+ name = "1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb";
+ path = fetchZigArtifact {
+ name = "zlib";
+ url = "https://github.com/madler/zlib/archive/refs/tags/v1.3.1.tar.gz";
+ hash = "sha256-F+iIY/NgBnKrSRgvIXKBtvxNPHYr3jYZNeQ2qVIU0Fw=";
+ };
+ }
+ {
+ name = "12201149afb3326c56c05bb0a577f54f76ac20deece63aa2f5cd6ff31a4fa4fcb3b7";
+ path = fetchZigArtifact {
+ name = "fontconfig";
+ url = "https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz";
+ hash = "sha256-O6LdkhWHGKzsXKrxpxYEO1qgVcJ7CB2RSvPMtA3OilU=";
+ };
+ }
+ {
+ name = "122032442d95c3b428ae8e526017fad881e7dc78eab4d558e9a58a80bfbd65a64f7d";
+ path = fetchZigArtifact {
+ name = "libxml2";
+ url = "https://github.com/GNOME/libxml2/archive/refs/tags/v2.11.5.tar.gz";
+ hash = "sha256-bCgFni4+60K1tLFkieORamNGwQladP7jvGXNxdiaYhU=";
+ };
+ }
+ {
+ name = "1220b8588f106c996af10249bfa092c6fb2f35fbacb1505ef477a0b04a7dd1063122";
+ path = fetchZigArtifact {
+ name = "harfbuzz";
+ url = "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/8.4.0.tar.gz";
+ hash = "sha256-nxygiYE7BZRK0c6MfgGCEwJtNdybq0gKIeuHaDg5ZVY=";
+ };
+ }
+ {
+ name = "12205c83b8311a24b1d5ae6d21640df04f4b0726e314337c043cde1432758cbe165b";
+ path = fetchZigArtifact {
+ name = "highway";
+ url = "https://github.com/google/highway/archive/refs/tags/1.1.0.tar.gz";
+ hash = "sha256-NUqLRTm1iOcLmOxwhEJz4/J0EwLEw3e8xOgbPRhm98k=";
+ };
+ }
+ {
+ name = "1220c15e72eadd0d9085a8af134904d9a0f5dfcbed5f606ad60edc60ebeccd9706bb";
+ path = fetchZigArtifact {
+ name = "oniguruma";
+ url = "https://github.com/kkos/oniguruma/archive/refs/tags/v6.9.9.tar.gz";
+ hash = "sha256-ABqhIC54RI9MC/GkjHblVodrNvFtks4yB+zP1h2Z8qA=";
+ };
+ }
+ {
+ name = "1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e";
+ path = fetchZigArtifact {
+ name = "sentry";
+ url = "https://github.com/getsentry/sentry-native/archive/refs/tags/0.7.8.tar.gz";
+ hash = "sha256-KsZJfMjWGo0xCT5HrduMmyxFsWsHBbszSoNbZCPDGN8=";
+ };
+ }
+ {
+ name = "12207fd37bb8251919c112dcdd8f616a491857b34a451f7e4486490077206dc2a1ea";
+ path = fetchZigArtifact {
+ name = "breakpad";
+ url = "https://github.com/getsentry/breakpad/archive/b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz";
+ hash = "sha256-bMqYlD0amQdmzvYQd8Ca/1k4Bj/heh7+EijlQSttatk=";
+ };
+ }
+ {
+ name = "1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641";
+ path = fetchZigArtifact {
+ name = "utfcpp";
+ url = "https://github.com/nemtrif/utfcpp/archive/refs/tags/v4.0.5.tar.gz";
+ hash = "sha256-/8ZooxDndgfTk/PBizJxXyI9oerExNbgV5oR345rWc8=";
+ };
+ }
+ {
+ name = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd";
+ path = fetchZigArtifact {
+ name = "wuffs";
+ url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz";
+ hash = "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=";
+ };
+ }
+ {
+ name = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806";
+ path = fetchZigArtifact {
+ name = "pixels";
+ url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877";
+ hash = "sha256-kXYGO0qn2PfyOYCrRA49BHIgTzdmKhI8SNO1ZKfUYEE=";
+ };
+ }
+ {
+ name = "12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1";
+ path = fetchZigArtifact {
+ name = "glslang";
+ url = "https://github.com/KhronosGroup/glslang/archive/refs/tags/14.2.0.tar.gz";
+ hash = "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=";
+ };
+ }
+ {
+ name = "1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da";
+ path = fetchZigArtifact {
+ name = "spirv_cross";
+ url = "https://github.com/KhronosGroup/SPIRV-Cross/archive/476f384eb7d9e48613c45179e502a15ab95b6b49.tar.gz";
+ hash = "sha256-tStvz8Ref6abHwahNiwVVHNETizAmZVVaxVsU7pmV+M=";
+ };
+ }
+ ]
diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop
old mode 100644
new mode 100755
diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py
index 4ef526019d..2cb20dd5d5 100644
--- a/dist/macos/update_appcast_tag.py
+++ b/dist/macos/update_appcast_tag.py
@@ -21,6 +21,7 @@
now = datetime.now(timezone.utc)
version = os.environ["GHOSTTY_VERSION"]
+version_dash = version.replace('.', '-')
build = os.environ["GHOSTTY_BUILD"]
commit = os.environ["GHOSTTY_COMMIT"]
commit_long = os.environ["GHOSTTY_COMMIT_LONG"]
@@ -82,6 +83,8 @@
elem.text = f"{version}"
elem = ET.SubElement(item, "sparkle:minimumSystemVersion")
elem.text = "13.0.0"
+elem = ET.SubElement(item, "sparkle:fullReleaseNotesLink")
+elem.text = f"https://ghostty.org/docs/install/release-notes/{version_dash}"
elem = ET.SubElement(item, "description")
elem.text = f"""
Ghostty v{version}
@@ -91,8 +94,8 @@
We don't currently generate release notes for auto-updates.
-You can view the complete changelog and release notes on
-the Ghostty website.
+You can view the complete changelog and release notes
+at ghostty.org/docs/install/release-notes/{version_dash}.
"""
elem = ET.SubElement(item, "enclosure")
diff --git a/flake.lock b/flake.lock
index bf678156b7..7905635b3b 100644
--- a/flake.lock
+++ b/flake.lock
@@ -3,11 +3,11 @@
"flake-compat": {
"flake": false,
"locked": {
- "lastModified": 1696426674,
- "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+ "lastModified": 1733328505,
+ "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
- "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
@@ -21,11 +21,11 @@
"systems": "systems"
},
"locked": {
- "lastModified": 1705309234,
- "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
- "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
},
"nixpkgs-stable": {
"locked": {
- "lastModified": 1733423277,
- "narHash": "sha256-TxabjxEgkNbCGFRHgM/b9yZWlBj60gUOUnRT/wbVQR8=",
+ "lastModified": 1738255539,
+ "narHash": "sha256-hP2eOqhIO/OILW+3moNWO4GtdJFYCqAe9yJZgvlCoDQ=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "e36963a147267afc055f7cf65225958633e536bf",
+ "rev": "c3511a3b53b482aa7547c9d1626fd7310c1de1c5",
"type": "github"
},
"original": {
@@ -52,11 +52,11 @@
},
"nixpkgs-unstable": {
"locked": {
- "lastModified": 1733229606,
- "narHash": "sha256-FLYY5M0rpa5C2QAE3CKLYAM6TwbKicdRK6qNrSHlNrE=",
+ "lastModified": 1738136902,
+ "narHash": "sha256-pUvLijVGARw4u793APze3j6mU1Zwdtz7hGkGGkD87qw=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "566e53c2ad750c84f6d31f9ccb9d00f823165550",
+ "rev": "9a5db3142ce450045840cc8d832b13b8a2018e0c",
"type": "github"
},
"original": {
@@ -69,9 +69,11 @@
"root": {
"inputs": {
"flake-compat": "flake-compat",
+ "flake-utils": "flake-utils",
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable",
- "zig": "zig"
+ "zig": "zig",
+ "zig2nix": "zig2nix"
}
},
"systems": {
@@ -92,17 +94,19 @@
"zig": {
"inputs": {
"flake-compat": [],
- "flake-utils": "flake-utils",
+ "flake-utils": [
+ "flake-utils"
+ ],
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
- "lastModified": 1717848532,
- "narHash": "sha256-d+xIUvSTreHl8pAmU1fnmkfDTGQYCn2Rb/zOwByxS2M=",
+ "lastModified": 1738239110,
+ "narHash": "sha256-Y5i9mQ++dyIQr+zEPNy+KIbc5wjPmfllBrag3cHZgcE=",
"owner": "mitchellh",
"repo": "zig-overlay",
- "rev": "02fc5cc555fc14fda40c42d7c3250efa43812b43",
+ "rev": "1a8fb6f3a04724519436355564b95fce5e272504",
"type": "github"
},
"original": {
@@ -110,6 +114,30 @@
"repo": "zig-overlay",
"type": "github"
}
+ },
+ "zig2nix": {
+ "inputs": {
+ "flake-utils": [
+ "flake-utils"
+ ],
+ "nixpkgs": [
+ "nixpkgs-stable"
+ ]
+ },
+ "locked": {
+ "lastModified": 1738263917,
+ "narHash": "sha256-j/3fwe2pEOquHabP/puljOKwAZFjIE9gXZqA91sC48M=",
+ "owner": "jcollie",
+ "repo": "zig2nix",
+ "rev": "c311d8e77a6ee0d995f40a6e10a89a3a4ab04f9a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "jcollie",
+ "ref": "c311d8e77a6ee0d995f40a6e10a89a3a4ab04f9a",
+ "repo": "zig2nix",
+ "type": "github"
+ }
}
},
"root": "root",
diff --git a/flake.nix b/flake.nix
index 83d4af4144..df0eeb759e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -8,6 +8,7 @@
# glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for.
nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11";
+ flake-utils.url = "github:numtide/flake-utils";
# Used for shell.nix
flake-compat = {
@@ -19,9 +20,18 @@
url = "github:mitchellh/zig-overlay";
inputs = {
nixpkgs.follows = "nixpkgs-stable";
+ flake-utils.follows = "flake-utils";
flake-compat.follows = "";
};
};
+
+ zig2nix = {
+ url = "github:jcollie/zig2nix?ref=c311d8e77a6ee0d995f40a6e10a89a3a4ab04f9a";
+ inputs = {
+ nixpkgs.follows = "nixpkgs-stable";
+ flake-utils.follows = "flake-utils";
+ };
+ };
};
outputs = {
@@ -29,40 +39,86 @@
nixpkgs-unstable,
nixpkgs-stable,
zig,
+ zig2nix,
...
}:
- builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let
- pkgs-stable = nixpkgs-stable.legacyPackages.${system};
- pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
- in {
- devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
- zig = zig.packages.${system}."0.13.0";
- wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
- };
+ builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
+ builtins.map (
+ system: let
+ pkgs-stable = nixpkgs-stable.legacyPackages.${system};
+ pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
+ in {
+ devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
+ zig = zig.packages.${system}."0.13.0";
+ wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
+ zig2nix = zig2nix;
+ };
- packages.${system} = let
- mkArgs = optimize: {
- inherit optimize;
+ packages.${system} = let
+ mkArgs = optimize: {
+ inherit optimize;
- revision = self.shortRev or self.dirtyShortRev or "dirty";
- };
- in rec {
- ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
- ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
- ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
+ revision = self.shortRev or self.dirtyShortRev or "dirty";
+ };
+ in rec {
+ deps = pkgs-stable.callPackage ./build.zig.zon.nix {};
+ ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
+ ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
+ ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
- ghostty = ghostty-releasefast;
- default = ghostty;
- };
+ ghostty = ghostty-releasefast;
+ default = ghostty;
+ };
+
+ formatter.${system} = pkgs-stable.alejandra;
- formatter.${system} = pkgs-stable.alejandra;
+ apps.${system} = let
+ runVM = (
+ module: let
+ vm = import ./nix/vm/create.nix {
+ inherit system module;
+ nixpkgs = nixpkgs-stable;
+ overlay = self.overlays.debug;
+ };
+ program = pkgs-stable.writeShellScript "run-ghostty-vm" ''
+ SHARED_DIR=$(pwd)
+ export SHARED_DIR
- # Our supported systems are the same supported systems as the Zig binaries.
- }) (builtins.attrNames zig.packages))
+ ${pkgs-stable.lib.getExe vm.config.system.build.vm} "$@"
+ '';
+ in {
+ type = "app";
+ program = "${program}";
+ }
+ );
+ in {
+ wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
+ wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
+ wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
+ x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
+ x11-gnome = runVM ./nix/vm/x11-gnome.nix;
+ x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
+ x11-xfce = runVM ./nix/vm/x11-xfce.nix;
+ };
+ }
+ # Our supported systems are the same supported systems as the Zig binaries.
+ ) (builtins.attrNames zig.packages)
+ )
// {
- overlays.default = final: prev: {
- ghostty = self.packages.${prev.system}.default;
+ overlays = {
+ default = self.overlays.releasefast;
+ releasefast = final: prev: {
+ ghostty = self.packages.${prev.system}.ghostty-releasefast;
+ };
+ debug = final: prev: {
+ ghostty = self.packages.${prev.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 = {
diff --git a/include/ghostty.h b/include/ghostty.h
index 29da8f37bd..99276cf23c 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -159,7 +159,7 @@ typedef enum {
GHOSTTY_KEY_EQUAL,
GHOSTTY_KEY_LEFT_BRACKET, // [
GHOSTTY_KEY_RIGHT_BRACKET, // ]
- GHOSTTY_KEY_BACKSLASH, // /
+ GHOSTTY_KEY_BACKSLASH, // \
// control
GHOSTTY_KEY_UP,
@@ -565,6 +565,7 @@ typedef enum {
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
+ GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
@@ -643,7 +644,7 @@ typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
ghostty_clipboard_e,
bool);
typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
-typedef void (*ghostty_runtime_action_cb)(ghostty_app_t,
+typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,
ghostty_target_s,
ghostty_action_s);
diff --git a/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..ffba7d94dc
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/BlueprintImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..eeedb72031
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/ChalkboardImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/Contents.json b/macos/Assets.xcassets/Alternate Icons/Contents.json
new file mode 100644
index 0000000000..73c00596a7
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..99d704e27b
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/GlassImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..b31c9e9736
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/HolographicImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..add488d362
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/MicrochipImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..fad8dc70b6
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/PaperImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..02619e860e
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/RetroImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/Contents.json b/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/Contents.json
new file mode 100644
index 0000000000..1c1b9b47eb
--- /dev/null
+++ b/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "macOS-AppIcon-1024px.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/macOS-AppIcon-1024px.png
new file mode 100644
index 0000000000..9e74a967c2
Binary files /dev/null and b/macos/Assets.xcassets/Alternate Icons/XrayImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index efa4a07c97..0c68da5346 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -69,7 +69,10 @@
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
+ A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; };
+ A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; };
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
+ A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; };
@@ -163,7 +166,10 @@
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; };
+ A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; };
+ A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; };
A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; };
+ A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = ""; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; };
@@ -266,11 +272,13 @@
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
+ A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
A5CEAFFE29C2410700646FDA /* Backport.swift */,
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
+ A5A2A3C92D4445E20033CF96 /* Dock.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
@@ -278,6 +286,7 @@
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
+ A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
@@ -617,6 +626,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
@@ -635,6 +645,7 @@
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
+ A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@@ -657,6 +668,7 @@
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
+ A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
diff --git a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index d380910a8b..5ace476e08 100644
--- a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
- "revision" : "b456fd404954a9e13f55aa0c88cd5a40b8399638",
- "version" : "2.6.3"
+ "revision" : "0ef1ee0220239b3776f433314515fd849025673f",
+ "version" : "2.6.4"
}
}
],
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 4b11b68aa6..09a86de1fb 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -93,7 +93,7 @@ class AppDelegate: NSObject,
}
/// Tracks the windows that we hid for toggleVisibility.
- private var hiddenWindows: [Weak] = []
+ private var hiddenState: ToggleVisibilityState? = nil
/// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil
@@ -217,8 +217,8 @@ class AppDelegate: NSObject,
}
func applicationDidBecomeActive(_ notification: Notification) {
- // If we're back then clear the hidden windows
- self.hiddenWindows = []
+ // If we're back manually then clear the hidden state because macOS handles it.
+ self.hiddenState = nil
// First launch stuff
if (!applicationHasBecomeActive) {
@@ -245,7 +245,13 @@ class AppDelegate: NSObject,
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
- if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow }
+ //
+ // NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
+ // here because I don't want to remove it in a patch release cycle but we should
+ // target removing it soon.
+ if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) {
+ return .terminateNow
+ }
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
why: if let event = NSAppleEventManager.shared().currentAppleEvent {
@@ -431,7 +437,7 @@ class AppDelegate: NSObject,
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event }
-
+
// If this event as-is would result in a key binding then we send it.
if let app = ghostty.app,
ghostty_app_key_is_binding(
@@ -447,26 +453,26 @@ class AppDelegate: NSObject,
return nil
}
}
-
+
// If this event would be handled by our menu then we do nothing.
if let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) {
return nil
}
-
+
// If we reach this point then we try to process the key event
// through the Ghostty key mechanism.
-
+
// Ghostty must be loaded
guard let ghostty = self.ghostty.app else { return event }
-
+
// Build our event input and call ghostty
if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
// The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event)")
return nil
}
-
+
return event
}
@@ -557,6 +563,30 @@ class AppDelegate: NSObject,
self.appIcon = nil
break
+ case .blueprint:
+ self.appIcon = NSImage(named: "BlueprintImage")!
+
+ case .chalkboard:
+ self.appIcon = NSImage(named: "ChalkboardImage")!
+
+ case .glass:
+ self.appIcon = NSImage(named: "GlassImage")!
+
+ case .holographic:
+ self.appIcon = NSImage(named: "HolographicImage")!
+
+ case .microchip:
+ self.appIcon = NSImage(named: "MicrochipImage")!
+
+ case .paper:
+ self.appIcon = NSImage(named: "PaperImage")!
+
+ case .retro:
+ self.appIcon = NSImage(named: "RetroImage")!
+
+ case .xray:
+ self.appIcon = NSImage(named: "XrayImage")!
+
case .customStyle:
guard let ghostColor = config.macosIconGhostColor else { break }
guard let screenColors = config.macosIconScreenColor else { break }
@@ -711,9 +741,13 @@ class AppDelegate: NSObject,
@IBAction func toggleVisibility(_ sender: Any) {
// If we have focus, then we hide all windows.
if NSApp.isActive {
- // We need to keep track of the windows that were visible because we only
- // want to bring back these windows if we remove the toggle.
- self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) }
+ // Toggle visibility doesn't do anything if the focused window is native
+ // fullscreen. This is only relevant if Ghostty is active.
+ guard let keyWindow = NSApp.keyWindow,
+ !keyWindow.styleMask.contains(.fullScreen) else { return }
+
+ // Keep track of our hidden state to restore properly
+ self.hiddenState = .init()
NSApp.hide(nil)
return
}
@@ -724,8 +758,8 @@ class AppDelegate: NSObject,
// Bring all windows to the front. Note: we don't use NSApp.unhide because
// that will unhide ALL hidden windows. We want to only bring forward the
// ones that we hid.
- self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() }
- self.hiddenWindows = []
+ hiddenState?.restore()
+ hiddenState = nil
}
private struct DerivedConfig {
@@ -745,4 +779,33 @@ class AppDelegate: NSObject,
self.quickTerminalPosition = config.quickTerminalPosition
}
}
+
+ private struct ToggleVisibilityState {
+ let hiddenWindows: [Weak]
+ let keyWindow: Weak?
+
+ init() {
+ // We need to know the key window so that we can bring focus back to the
+ // right window if it was hidden.
+ self.keyWindow = if let keyWindow = NSApp.keyWindow {
+ .init(keyWindow)
+ } else {
+ nil
+ }
+
+ // We need to keep track of the windows that were visible because we only
+ // want to bring back these windows if we remove the toggle.
+ //
+ // We also ignore fullscreen windows because they don't hide anyways.
+ self.hiddenWindows = NSApp.windows.filter {
+ $0.isVisible &&
+ !$0.styleMask.contains(.fullScreen)
+ }.map { Weak($0) }
+ }
+
+ func restore() {
+ hiddenWindows.forEach { $0.value?.orderFrontRegardless() }
+ keyWindow?.value?.makeKey()
+ }
+ }
}
diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
index c23aad755e..8079358063 100644
--- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
+++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
@@ -27,6 +27,9 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown.
private var previousActiveSpace: size_t = 0
+ /// Non-nil if we have hidden dock state.
+ private var hiddenDock: HiddenDock? = nil
+
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
@@ -41,6 +44,11 @@ class QuickTerminalController: BaseTerminalController {
// Setup our notifications for behaviors
let center = NotificationCenter.default
+ center.addObserver(
+ self,
+ selector: #selector(applicationWillTerminate(_:)),
+ name: NSApplication.willTerminateNotification,
+ object: nil)
center.addObserver(
self,
selector: #selector(onToggleFullscreen),
@@ -61,6 +69,9 @@ class QuickTerminalController: BaseTerminalController {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
+
+ // Make sure we restore our hidden dock
+ hiddenDock = nil
}
// MARK: NSWindowController
@@ -96,6 +107,17 @@ class QuickTerminalController: BaseTerminalController {
// MARK: NSWindowDelegate
+ override func windowDidBecomeKey(_ notification: Notification) {
+ super.windowDidBecomeKey(notification)
+
+ // If we're not visible we don't care to run the logic below. It only
+ // applies if we can be seen.
+ guard visible else { return }
+
+ // Re-hide the dock if we were hiding it before.
+ hiddenDock?.hide()
+ }
+
override func windowDidResignKey(_ notification: Notification) {
super.windowDidResignKey(notification)
@@ -116,6 +138,10 @@ class QuickTerminalController: BaseTerminalController {
self.previousApp = nil
}
+ // Regardless of autohide, we always want to bring the dock back
+ // when we lose focus.
+ hiddenDock?.restore()
+
if derivedConfig.quickTerminalAutoHide {
switch derivedConfig.quickTerminalSpaceBehavior {
case .remain:
@@ -240,6 +266,20 @@ class QuickTerminalController: BaseTerminalController {
window.makeKeyAndOrderFront(nil)
}
+ // If our dock position would conflict with our target location then
+ // we autohide the dock.
+ if position.conflictsWithDock(on: screen) {
+ if (hiddenDock == nil) {
+ hiddenDock = .init()
+ }
+
+ hiddenDock?.hide()
+ } else {
+ // Ensure we don't have any hidden dock if we don't conflict.
+ // The deinit will restore.
+ hiddenDock = nil
+ }
+
// Run the animation that moves our window into the proper place and makes
// it visible.
NSAnimationContext.runAnimationGroup({ context in
@@ -250,8 +290,11 @@ class QuickTerminalController: BaseTerminalController {
// There is a very minor delay here so waiting at least an event loop tick
// keeps us safe from the view not being on the window.
DispatchQueue.main.async {
- // If we canceled our animation in we do nothing
- guard self.visible else { return }
+ // If we canceled our animation clean up some state.
+ guard self.visible else {
+ self.hiddenDock = nil
+ return
+ }
// After animating in, we reset the window level to a value that
// is above other windows but not as high as popUpMenu. This allows
@@ -320,6 +363,9 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
+ // If we hid the dock then we unhide it.
+ hiddenDock = nil
+
// If the window isn't on our active space then we don't animate, we just
// hide it.
if !window.isOnActiveSpace {
@@ -375,19 +421,6 @@ class QuickTerminalController: BaseTerminalController {
// Some APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { return }
- // Terminals typically operate in sRGB color space and macOS defaults
- // to "native" which is typically P3. There is a lot more resources
- // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
- // Ghostty defaults to sRGB but this can be overridden.
- switch (self.derivedConfig.windowColorspace) {
- case "display-p3":
- window.colorSpace = .displayP3
- case "srgb":
- fallthrough
- default:
- window.colorSpace = .sRGB
- }
-
// If we have window transparency then set it transparent. Otherwise set it opaque.
if (self.derivedConfig.backgroundOpacity < 1) {
window.isOpaque = false
@@ -428,6 +461,13 @@ class QuickTerminalController: BaseTerminalController {
// MARK: Notifications
+ @objc private func applicationWillTerminate(_ notification: Notification) {
+ // If the application is going to terminate we want to make sure we
+ // restore any global dock state. I think deinit should be called which
+ // would call this anyways but I can't be sure so I will do this too.
+ hiddenDock = nil
+ }
+
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
@@ -457,7 +497,6 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
- let windowColorspace: String
let backgroundOpacity: Double
init() {
@@ -465,7 +504,6 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true
self.quickTerminalSpaceBehavior = .move
- self.windowColorspace = ""
self.backgroundOpacity = 1.0
}
@@ -474,10 +512,38 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
- self.windowColorspace = config.windowColorspace
self.backgroundOpacity = config.backgroundOpacity
}
}
+
+ /// Hides the dock globally (not just NSApp). This is only used if the quick terminal is
+ /// in a conflicting position with the dock.
+ private class HiddenDock {
+ let previousAutoHide: Bool
+ private var hidden: Bool = false
+
+ init() {
+ previousAutoHide = Dock.autoHideEnabled
+ }
+
+ deinit {
+ restore()
+ }
+
+ func hide() {
+ guard !hidden else { return }
+ NSApp.acquirePresentationOption(.autoHideDock)
+ Dock.autoHideEnabled = true
+ hidden = true
+ }
+
+ func restore() {
+ guard hidden else { return }
+ NSApp.releasePresentationOption(.autoHideDock)
+ Dock.autoHideEnabled = previousAutoHide
+ hidden = false
+ }
+ }
}
extension Notification.Name {
diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift
index 0acbfec1b2..7ba124a309 100644
--- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift
+++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift
@@ -69,7 +69,7 @@ enum QuickTerminalPosition : String {
finalSize.width = screen.frame.width
case .left, .right:
- finalSize.height = screen.frame.height
+ finalSize.height = screen.visibleFrame.height
case .center:
finalSize.width = screen.frame.width / 2
@@ -118,4 +118,22 @@ enum QuickTerminalPosition : String {
return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
}
}
+
+ func conflictsWithDock(on screen: NSScreen) -> Bool {
+ // Screen must have a dock for it to conflict
+ guard screen.hasDock else { return false }
+
+ // Get the dock orientation for this screen
+ guard let orientation = Dock.orientation else { return false }
+
+ // Depending on the orientation of the dock, we conflict if our quick terminal
+ // would potentially "hit" the dock. In the future we should probably consider
+ // the frame of the quick terminal.
+ return switch (orientation) {
+ case .top: self == .top || self == .left || self == .right
+ case .bottom: self == .bottom || self == .left || self == .right
+ case .left: self == .top || self == .bottom
+ case .right: self == .top || self == .bottom
+ }
+ }
}
diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift
index bda6d62bf5..bace20f052 100644
--- a/macos/Sources/Features/Terminal/BaseTerminalController.swift
+++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift
@@ -452,6 +452,7 @@ class BaseTerminalController: NSWindowController,
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
+ alert.window.orderOut(nil)
window.close()
default:
diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift
index 08306a854e..8507cf6204 100644
--- a/macos/Sources/Features/Terminal/TerminalController.swift
+++ b/macos/Sources/Features/Terminal/TerminalController.swift
@@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController {
private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references.
- private var derivedConfig: DerivedConfig
+ private(set) var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set = []
@@ -283,9 +283,12 @@ class TerminalController: BaseTerminalController {
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
guard let window else { return }
- // If we don't have both an X and Y we center.
+ // If we don't have an X/Y then we try to use the previously saved window pos.
guard let x, let y else {
- window.center()
+ if (!LastWindowPosition.shared.restore(window)) {
+ window.center()
+ }
+
return
}
@@ -315,28 +318,28 @@ class TerminalController: BaseTerminalController {
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
-
+
// Full size content view so we can extend
// content in to the hidden titlebar's area
- .fullSizeContentView,
-
- .resizable,
+ .fullSizeContentView,
+
+ .resizable,
.closable,
.miniaturizable,
]
-
+
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
-
+
// Hide the traffic lights (window control buttons)
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
-
+
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
-
+
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
// some operations that appear to bring back the titlebar visibility so this ensures
// it is gone forever.
@@ -345,7 +348,7 @@ class TerminalController: BaseTerminalController {
titleBarContainer.isHidden = true
}
}
-
+
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow else { return }
@@ -366,19 +369,6 @@ class TerminalController: BaseTerminalController {
// If window decorations are disabled, remove our title
if (!config.windowDecorations) { window.styleMask.remove(.titled) }
- // Terminals typically operate in sRGB color space and macOS defaults
- // to "native" which is typically P3. There is a lot more resources
- // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
- // Ghostty defaults to sRGB but this can be overridden.
- switch (config.windowColorspace) {
- case "display-p3":
- window.colorSpace = .displayP3
- case "srgb":
- fallthrough
- default:
- window.colorSpace = .sRGB
- }
-
// If we have only a single surface (no splits) and that surface requested
// an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree {
@@ -503,6 +493,20 @@ class TerminalController: BaseTerminalController {
override func windowDidMove(_ notification: Notification) {
super.windowDidMove(notification)
self.fixTabBar()
+
+ // Whenever we move save our last position for the next start.
+ if let window {
+ LastWindowPosition.shared.save(window)
+ }
+ }
+
+ func windowDidBecomeMain(_ notification: Notification) {
+ // Whenever we get focused, use that as our last window position for
+ // restart. This differs from Terminal.app but matches iTerm2 behavior
+ // and I think its sensible.
+ if let window {
+ LastWindowPosition.shared.save(window)
+ }
}
// Called when the window will be encoded. We handle the data encoding here in the
@@ -789,7 +793,7 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode)
}
- private struct DerivedConfig {
+ struct DerivedConfig {
let backgroundColor: Color
let macosTitlebarStyle: String
diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift
index 0eb8daeeb3..9d29c193f5 100644
--- a/macos/Sources/Features/Terminal/TerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/TerminalWindow.swift
@@ -115,6 +115,21 @@ class TerminalWindow: NSWindow {
}
}
+ // We override this so that with the hidden titlebar style the titlebar
+ // area is not draggable.
+ override var contentLayoutRect: CGRect {
+ var rect = super.contentLayoutRect
+
+ // If we are using a hidden titlebar style, the content layout is the
+ // full frame making it so that it is not draggable.
+ if let controller = windowController as? TerminalController,
+ controller.derivedConfig.macosTitlebarStyle == "hidden" {
+ rect.origin.y = 0
+ rect.size.height = self.frame.height
+ }
+ return rect
+ }
+
// The window theme configuration from Ghostty. This is used to control some
// behaviors that don't look quite right in certain situations.
var windowTheme: TerminalWindowTheme?
diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift
index 43c0f245aa..ea9a775671 100644
--- a/macos/Sources/Ghostty/Ghostty.App.swift
+++ b/macos/Sources/Ghostty/Ghostty.App.swift
@@ -257,7 +257,7 @@ extension Ghostty {
// MARK: Ghostty Callbacks (iOS)
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
- static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) {}
+ static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool { return false }
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
@@ -423,7 +423,7 @@ extension Ghostty {
// MARK: Actions (macOS)
- static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) {
+ static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool {
// Make sure it a target we understand so all our action handlers can assert
switch (target.tag) {
case GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE:
@@ -431,7 +431,7 @@ extension Ghostty {
default:
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)")
- return
+ return false
}
// Action dispatch
@@ -541,10 +541,15 @@ extension Ghostty {
fallthrough
case GHOSTTY_ACTION_QUIT_TIMER:
Ghostty.logger.info("known but unimplemented action action=\(action.tag.rawValue)")
-
+ return false
default:
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)")
+ return false
}
+
+ // If we reached here then we assume performed since all unknown actions
+ // are captured in the switch and return false.
+ return true
}
private static func quit(_ app: ghostty_app_t) {
diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift
index 1b3263fc3c..9c8042c633 100644
--- a/macos/Sources/Ghostty/Ghostty.Config.swift
+++ b/macos/Sources/Ghostty/Ghostty.Config.swift
@@ -132,15 +132,6 @@ extension Ghostty {
return v
}
- var windowColorspace: String {
- guard let config = self.config else { return "" }
- var v: UnsafePointer? = nil
- let key = "window-colorspace"
- guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" }
- guard let ptr = v else { return "" }
- return String(cString: ptr)
- }
-
var windowSaveState: String {
guard let config = self.config else { return "" }
var v: UnsafePointer? = nil
@@ -174,11 +165,14 @@ extension Ghostty {
}
var windowDecorations: Bool {
- guard let config = self.config else { return true }
- var v = false;
+ let defaultValue = true
+ guard let config = self.config else { return defaultValue }
+ var v: UnsafePointer? = nil
let key = "window-decoration"
- _ = ghostty_config_get(config, &v, key, UInt(key.count))
- return v;
+ guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
+ guard let ptr = v else { return defaultValue }
+ let str = String(cString: ptr)
+ return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue
}
var windowTheme: String? {
@@ -345,7 +339,7 @@ extension Ghostty {
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
- let key = "background-blur-radius"
+ let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
}
@@ -563,4 +557,18 @@ extension Ghostty.Config {
}
}
}
+
+ enum WindowDecoration: String {
+ case none
+ case client
+ case server
+ case auto
+
+ func enabled() -> Bool {
+ switch self {
+ case .client, .server, .auto: return true
+ case .none: return false
+ }
+ }
+ }
}
diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift
index cc3bef1492..cec1782459 100644
--- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift
+++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift
@@ -205,6 +205,7 @@ extension Ghostty {
alert.beginSheetModal(for: window, completionHandler: { response in
switch (response) {
case .alertFirstButtonReturn:
+ alert.window.orderOut(nil)
node = nil
default:
diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift
index 71fac4a993..18ef3f3a73 100644
--- a/macos/Sources/Ghostty/Package.swift
+++ b/macos/Sources/Ghostty/Package.swift
@@ -198,6 +198,14 @@ extension Ghostty {
/// macos-icon
enum MacOSIcon: String {
case official
+ case blueprint
+ case chalkboard
+ case glass
+ case holographic
+ case microchip
+ case paper
+ case retro
+ case xray
case customStyle = "custom-style"
}
diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Dock.swift
new file mode 100644
index 0000000000..a71fcaa5b2
--- /dev/null
+++ b/macos/Sources/Helpers/Dock.swift
@@ -0,0 +1,38 @@
+import Cocoa
+
+// Private API to get Dock location
+@_silgen_name("CoreDockGetOrientationAndPinning")
+func CoreDockGetOrientationAndPinning(
+ _ outOrientation: UnsafeMutablePointer,
+ _ outPinning: UnsafeMutablePointer)
+
+// Private API to get the current Dock auto-hide state
+@_silgen_name("CoreDockGetAutoHideEnabled")
+func CoreDockGetAutoHideEnabled() -> Bool
+
+// Toggles the Dock's auto-hide state
+@_silgen_name("CoreDockSetAutoHideEnabled")
+func CoreDockSetAutoHideEnabled(_ flag: Bool)
+
+enum DockOrientation: Int {
+ case top = 1
+ case bottom = 2
+ case left = 3
+ case right = 4
+}
+
+class Dock {
+ /// Returns the orientation of the dock or nil if it can't be determined.
+ static var orientation: DockOrientation? {
+ var orientation: Int32 = 0
+ var pinning: Int32 = 0
+ CoreDockGetOrientationAndPinning(&orientation, &pinning)
+ return .init(rawValue: Int(orientation)) ?? nil
+ }
+
+ /// Set the dock autohide.
+ static var autoHideEnabled: Bool {
+ get { return CoreDockGetAutoHideEnabled() }
+ set { CoreDockSetAutoHideEnabled(newValue) }
+ }
+}
diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift
index a16f329f88..320eca0134 100644
--- a/macos/Sources/Helpers/Fullscreen.swift
+++ b/macos/Sources/Helpers/Fullscreen.swift
@@ -307,21 +307,21 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// MARK: Dock
private func hideDock() {
- NSApp.presentationOptions.insert(.autoHideDock)
+ NSApp.acquirePresentationOption(.autoHideDock)
}
private func unhideDock() {
- NSApp.presentationOptions.remove(.autoHideDock)
+ NSApp.releasePresentationOption(.autoHideDock)
}
// MARK: Menu
func hideMenu() {
- NSApp.presentationOptions.insert(.autoHideMenuBar)
+ NSApp.acquirePresentationOption(.autoHideMenuBar)
}
func unhideMenu() {
- NSApp.presentationOptions.remove(.autoHideMenuBar)
+ NSApp.releasePresentationOption(.autoHideMenuBar)
}
/// The state that must be saved for non-native fullscreen to exit fullscreen.
diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift
new file mode 100644
index 0000000000..a0dfa90dda
--- /dev/null
+++ b/macos/Sources/Helpers/LastWindowPosition.swift
@@ -0,0 +1,34 @@
+import Cocoa
+
+/// Manages the persistence and restoration of window positions across app launches.
+class LastWindowPosition {
+ static let shared = LastWindowPosition()
+
+ private let positionKey = "NSWindowLastPosition"
+
+ func save(_ window: NSWindow) {
+ let origin = window.frame.origin
+ let point = [origin.x, origin.y]
+ UserDefaults.standard.set(point, forKey: positionKey)
+ }
+
+ func restore(_ window: NSWindow) -> Bool {
+ guard let points = UserDefaults.standard.array(forKey: positionKey) as? [Double],
+ points.count == 2 else { return false }
+
+ let lastPosition = CGPoint(x: points[0], y: points[1])
+
+ guard let screen = window.screen ?? NSScreen.main else { return false }
+ let visibleFrame = screen.visibleFrame
+
+ var newFrame = window.frame
+ newFrame.origin = lastPosition
+ if !visibleFrame.contains(newFrame.origin) {
+ newFrame.origin.x = max(visibleFrame.minX, min(visibleFrame.maxX - newFrame.width, newFrame.origin.x))
+ newFrame.origin.y = max(visibleFrame.minY, min(visibleFrame.maxY - newFrame.height, newFrame.origin.y))
+ }
+
+ window.setFrame(newFrame, display: true)
+ return true
+ }
+}
diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift
new file mode 100644
index 0000000000..0580cd5fc7
--- /dev/null
+++ b/macos/Sources/Helpers/NSApplication+Extension.swift
@@ -0,0 +1,31 @@
+import Cocoa
+
+extension NSApplication {
+ private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:]
+
+ /// Add a presentation option to the application and main a reference count so that and equal
+ /// number of pops is required to disable it. This is useful so that multiple classes can affect global
+ /// app state without overriding others.
+ func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
+ Self.presentationOptionCounts[option, default: 0] += 1
+ presentationOptions.insert(option)
+ }
+
+ /// See acquirePresentationOption
+ func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
+ guard let value = Self.presentationOptionCounts[option] else { return }
+ guard value > 0 else { return }
+ if (value == 1) {
+ presentationOptions.remove(option)
+ Self.presentationOptionCounts.removeValue(forKey: option)
+ } else {
+ Self.presentationOptionCounts[option] = value - 1
+ }
+ }
+}
+
+extension NSApplication.PresentationOptions.Element: @retroactive Hashable {
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(rawValue)
+ }
+}
diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift
index 7315739c6f..11815fbc87 100644
--- a/macos/Sources/Helpers/NSPasteboard+Extension.swift
+++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift
@@ -9,13 +9,15 @@ extension NSPasteboard {
/// Gets the contents of the pasteboard as a string following a specific set of semantics.
/// Does these things in order:
- /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one.
+ /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard.
/// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? {
if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
- return urls.map { $0.path }.joined(separator: " ")
+ return urls
+ .map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString }
+ .joined(separator: " ")
}
return self.string(forType: .string)
diff --git a/nix/build-support/check-zig-cache-hash.sh b/nix/build-support/check-zig-cache-hash.sh
deleted file mode 100755
index 49ea29ffbf..0000000000
--- a/nix/build-support/check-zig-cache-hash.sh
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env bash
-
-# Nothing in this script should fail.
-set -e
-
-CACHE_HASH_FILE="$(realpath "$(dirname "$0")/../zigCacheHash.nix")"
-
-help() {
- echo ""
- echo "To fix, please (manually) re-run the script from the repository root,"
- echo "commit, and push the update:"
- echo ""
- echo " ./nix/build-support/check-zig-cache-hash.sh --update"
- echo " git add nix/zigCacheHash.nix"
- echo " git commit -m \"nix: update Zig cache hash\""
- echo " git push"
- echo ""
-}
-
-if [ -f "${CACHE_HASH_FILE}" ]; then
- OLD_CACHE_HASH="$(nix eval --raw --file "${CACHE_HASH_FILE}")"
-elif [ "$1" != "--update" ]; then
- echo -e "\nERROR: Zig cache hash file missing."
- help
- exit 1
-fi
-
-ZIG_GLOBAL_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)"
-export ZIG_GLOBAL_CACHE_DIR
-
-# This is not 100% necessary in CI but is helpful when running locally to keep
-# a local workstation clean.
-trap 'rm -rf "${ZIG_GLOBAL_CACHE_DIR}"' EXIT
-
-# Run Zig and download the cache to the temporary directory.
-
-sh ./nix/build-support/fetch-zig-cache.sh
-
-# Now, calculate the hash.
-ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${ZIG_GLOBAL_CACHE_DIR}")")"
-
-if [ "${OLD_CACHE_HASH}" == "${ZIG_CACHE_HASH}" ]; then
- echo -e "\nOK: Zig cache store hash unchanged."
- exit 0
-elif [ "$1" != "--update" ]; then
- echo -e "\nERROR: The Zig cache store hash has updated."
- echo ""
- echo " * Old hash: ${OLD_CACHE_HASH}"
- echo " * New hash: ${ZIG_CACHE_HASH}"
- help
- exit 1
-else
- echo -e "\nNew Zig cache store hash: ${ZIG_CACHE_HASH}"
-fi
-
-# Write out the cache file
-cat > "${CACHE_HASH_FILE}" < "$WORK_DIR/build.zig.zon.nix"
+alejandra --quiet "$WORK_DIR/build.zig.zon.nix"
+rm -f "$BUILD_ZIG_ZON_LOCK"
+
+NEW_HASH=$(sha512sum "$WORK_DIR/build.zig.zon.nix" | awk '{print $1}')
+
+if [ "${OLD_HASH}" == "${NEW_HASH}" ]; then
+ echo -e "\nOK: build.zig.zon.nix unchanged."
+ exit 0
+elif [ "$1" != "--update" ]; then
+ echo -e "\nERROR: build.zig.zon.nix needs to be updated."
+ echo ""
+ echo " * Old hash: ${OLD_HASH}"
+ echo " * New hash: ${NEW_HASH}"
+ help
+ exit 1
+else
+ mv "$WORK_DIR/build.zig.zon.nix" "$BUILD_ZIG_ZON_NIX"
+ echo -e "\nOK: build.zig.zon.nix updated."
+ exit 0
+fi
+
diff --git a/nix/build-support/fetch-zig-cache.sh b/nix/build-support/fetch-zig-cache.sh
deleted file mode 100755
index 56b94e35df..0000000000
--- a/nix/build-support/fetch-zig-cache.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/sh
-
-set -e
-
-# Because Zig does not fetch recursive dependencies when you run `zig build
-# --fetch` (see https://github.com/ziglang/zig/issues/20976) we need to do some
-# extra work to fetch everything that we actually need to build without Internet
-# access (such as when building a Nix package).
-#
-# An example of this happening:
-#
-# error: builder for '/nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv' failed with exit code 1;
-# la/build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:7:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure
-# > .url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e",
-# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-# > /build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:16:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure
-# > .url = "git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b",
-# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-# >
-# For full logs, run 'nix log /nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv'.
-#
-# To update this script, add any failing URLs with a line like this:
-#
-# zig fetch
-#
-# Periodically old URLs may need to be cleaned out.
-#
-# Hopefully when the Zig issue is fixed this script can be eliminated in favor
-# of a plain `zig build --fetch`.
-
-if [ -z ${ZIG_GLOBAL_CACHE_DIR+x} ]
-then
- echo "must set ZIG_GLOBAL_CACHE_DIR!"
- exit 1
-fi
-
-zig build --fetch
-zig fetch git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e
-zig fetch git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b
diff --git a/nix/devShell.nix b/nix/devShell.nix
index c52afb6c0c..3014b34b73 100644
--- a/nix/devShell.nix
+++ b/nix/devShell.nix
@@ -30,6 +30,7 @@
glib,
glslang,
gtk4,
+ gobject-introspection,
libadwaita,
adwaita-icon-theme,
hicolor-icon-theme,
@@ -54,6 +55,8 @@
wayland,
wayland-scanner,
wayland-protocols,
+ zig2nix,
+ system,
}: let
# See package.nix. Keep in sync.
rpathLibs =
@@ -83,6 +86,7 @@
libadwaita
gtk4
glib
+ gobject-introspection
wayland
];
in
@@ -100,6 +104,7 @@ in
scdoc
zig
zip
+ zig2nix.packages.${system}.zon2nix
# For web and wasm stuff
nodejs
@@ -157,6 +162,7 @@ in
libadwaita
gtk4
glib
+ gobject-introspection
wayland
wayland-scanner
wayland-protocols
diff --git a/nix/package.nix b/nix/package.nix
index 2f7825a562..892d5e956c 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -2,6 +2,7 @@
lib,
stdenv,
bzip2,
+ callPackage,
expat,
fontconfig,
freetype,
@@ -12,6 +13,7 @@
libGL,
glib,
gtk4,
+ gobject-introspection,
libadwaita,
wrapGAppsHook4,
gsettings-desktop-schemas,
@@ -40,82 +42,36 @@
# ultimately acted on and has made its way to a nixpkgs implementation, this
# can probably be removed in favor of that.
zig_hook = zig_0_13.hook.overrideAttrs {
- zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize}";
- };
-
- # We limit source like this to try and reduce the amount of rebuilds as possible
- # thus we only provide the source that is needed for the build
- #
- # NOTE: as of the current moment only linux files are provided,
- # since darwin support is not finished
- src = lib.fileset.toSource {
- root = ../.;
- fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
- lib.fileset.unions [
- ../dist/linux
- ../images
- ../include
- ../pkg
- ../src
- ../vendor
- ../build.zig
- ../build.zig.zon
- ./build-support/fetch-zig-cache.sh
- ]
- );
- };
-
- # This hash is the computation of the zigCache fixed-output derivation. This
- # allows us to use remote package dependencies without breaking the sandbox.
- #
- # This will need updating whenever dependencies get updated (e.g. changes are
- # made to zig.build.zon). If you see that the main build is trying to reach
- # out to the internet and failing, this is likely the cause. Change this
- # value back to lib.fakeHash, and re-run. The build failure should emit the
- # updated hash, which of course, should be validated before updating here.
- #
- # (It's also possible that you might see a hash mismatch - without the
- # network errors - if you don't have a previous instance of the cache
- # derivation in your store already. If so, just update the value as above.)
- zigCacheHash = import ./zigCacheHash.nix;
-
- zigCache = stdenv.mkDerivation {
- inherit src;
- name = "ghostty-cache";
- nativeBuildInputs = [
- git
- zig_hook
- ];
-
- dontConfigure = true;
- dontUseZigBuild = true;
- dontUseZigInstall = true;
- dontFixup = true;
-
- buildPhase = ''
- runHook preBuild
-
- sh ./nix/build-support/fetch-zig-cache.sh
-
- runHook postBuild
- '';
-
- installPhase = ''
- runHook preInstall
-
- cp -r --reflink=auto $ZIG_GLOBAL_CACHE_DIR $out
-
- runHook postInstall
- '';
-
- outputHashMode = "recursive";
- outputHash = zigCacheHash;
+ zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off";
};
in
stdenv.mkDerivation (finalAttrs: {
pname = "ghostty";
- version = "1.0.2";
- inherit src;
+ version = "1.1.1";
+
+ # We limit source like this to try and reduce the amount of rebuilds as possible
+ # thus we only provide the source that is needed for the build
+ #
+ # NOTE: as of the current moment only linux files are provided,
+ # since darwin support is not finished
+ src = lib.fileset.toSource {
+ root = ../.;
+ fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
+ lib.fileset.unions [
+ ../dist/linux
+ ../images
+ ../include
+ ../pkg
+ ../src
+ ../vendor
+ ../build.zig
+ ../build.zig.zon
+ ../build.zig.zon.nix
+ ]
+ );
+ };
+
+ deps = callPackage ../build.zig.zon.nix {name = "ghostty-cache-${finalAttrs.version}";};
nativeBuildInputs =
[
@@ -124,6 +80,7 @@ in
pandoc
pkg-config
zig_hook
+ gobject-introspection
wrapGAppsHook4
]
++ lib.optionals enableWayland [
@@ -162,13 +119,13 @@ in
dontConfigure = true;
- zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString enableX11} -Dgtk-wayland=${lib.boolToString enableWayland}";
-
- preBuild = ''
- rm -rf $ZIG_GLOBAL_CACHE_DIR
- cp -r --reflink=auto ${zigCache} $ZIG_GLOBAL_CACHE_DIR
- chmod u+rwX -R $ZIG_GLOBAL_CACHE_DIR
- '';
+ zigBuildFlags = [
+ "--system"
+ "${finalAttrs.deps}"
+ "-Dversion-string=${finalAttrs.version}-${revision}-nix"
+ "-Dgtk-x11=${lib.boolToString enableX11}"
+ "-Dgtk-wayland=${lib.boolToString enableWayland}"
+ ];
outputs = [
"out"
@@ -202,7 +159,7 @@ in
'';
meta = {
- homepage = "https://github.com/ghostty-org/ghostty";
+ homepage = "https://ghostty.org";
license = lib.licenses.mit;
platforms = [
"x86_64-linux"
diff --git a/nix/vm/common-cinnamon.nix b/nix/vm/common-cinnamon.nix
new file mode 100644
index 0000000000..dabe5e7018
--- /dev/null
+++ b/nix/vm/common-cinnamon.nix
@@ -0,0 +1,18 @@
+{...}: {
+ imports = [
+ ./common.nix
+ ];
+
+ services.xserver = {
+ displayManager = {
+ lightdm = {
+ enable = true;
+ };
+ };
+ desktopManager = {
+ cinnamon = {
+ enable = true;
+ };
+ };
+ };
+}
diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix
new file mode 100644
index 0000000000..0c2bef150c
--- /dev/null
+++ b/nix/vm/common-gnome.nix
@@ -0,0 +1,136 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}: {
+ imports = [
+ ./common.nix
+ ];
+
+ services.xserver = {
+ displayManager = {
+ gdm = {
+ enable = true;
+ autoSuspend = false;
+ };
+ };
+ desktopManager = {
+ gnome = {
+ enable = true;
+ };
+ };
+ };
+
+ environment.systemPackages = [
+ pkgs.gnomeExtensions.no-overview
+ ];
+
+ environment.gnome.excludePackages = with pkgs; [
+ atomix
+ baobab
+ cheese
+ epiphany
+ evince
+ file-roller
+ geary
+ gnome-backgrounds
+ gnome-calculator
+ gnome-calendar
+ gnome-clocks
+ gnome-connections
+ gnome-contacts
+ gnome-disk-utility
+ gnome-extension-manager
+ gnome-logs
+ gnome-maps
+ gnome-music
+ gnome-photos
+ gnome-software
+ gnome-system-monitor
+ gnome-text-editor
+ gnome-themes-extra
+ gnome-tour
+ gnome-user-docs
+ gnome-weather
+ hitori
+ iagno
+ loupe
+ nautilus
+ orca
+ seahorse
+ simple-scan
+ snapshot
+ sushi
+ tali
+ totem
+ yelp
+ ];
+
+ programs.dconf = {
+ enable = true;
+ profiles.user.databases = [
+ {
+ settings = with lib.gvariant; {
+ "org/gnome/desktop/background" = {
+ picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
+ picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
+ picture-options = "centered";
+ primary-color = "#000000000000";
+ secondary-color = "#000000000000";
+ };
+ "org/gnome/desktop/interface" = {
+ color-scheme = "prefer-dark";
+ };
+ "org/gnome/desktop/notifications" = {
+ show-in-lock-screen = false;
+ };
+ "org/gnome/desktop/screensaver" = {
+ lock-enabled = false;
+ picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
+ picture-options = "centered";
+ primary-color = "#000000000000";
+ secondary-color = "#000000000000";
+ };
+ "org/gnome/desktop/session" = {
+ idle-delay = mkUint32 0;
+ };
+ "org/gnome/shell" = {
+ disable-user-extensions = false;
+ enabled-extensions = builtins.map (x: x.extensionUuid) (
+ lib.filter (p: p ? extensionUuid) config.environment.systemPackages
+ );
+ };
+ };
+ }
+ ];
+ };
+
+ programs.geary.enable = false;
+
+ services.gnome = {
+ gnome-browser-connector.enable = false;
+ gnome-initial-setup.enable = false;
+ gnome-online-accounts.enable = false;
+ gnome-remote-desktop.enable = false;
+ rygel.enable = false;
+ };
+
+ system.activationScripts = {
+ face = {
+ text = ''
+ mkdir -p /var/lib/AccountsService/{icons,users}
+
+ cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty
+
+ echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty
+
+ chown root:root /var/lib/AccountsService/users/ghostty
+ chmod 0600 /var/lib/AccountsService/users/ghostty
+
+ chown root:root /var/lib/AccountsService/icons/ghostty
+ chmod 0444 /var/lib/AccountsService/icons/ghostty
+ '';
+ };
+ };
+}
diff --git a/nix/vm/common-plasma6.nix b/nix/vm/common-plasma6.nix
new file mode 100644
index 0000000000..e5c9bd4d87
--- /dev/null
+++ b/nix/vm/common-plasma6.nix
@@ -0,0 +1,21 @@
+{...}: {
+ imports = [
+ ./common.nix
+ ];
+
+ services = {
+ displayManager = {
+ sddm = {
+ enable = true;
+ wayland = {
+ enable = true;
+ };
+ };
+ };
+ desktopManager = {
+ plasma6 = {
+ enable = true;
+ };
+ };
+ };
+}
diff --git a/nix/vm/common-xfce.nix b/nix/vm/common-xfce.nix
new file mode 100644
index 0000000000..12a20d8d86
--- /dev/null
+++ b/nix/vm/common-xfce.nix
@@ -0,0 +1,18 @@
+{...}: {
+ imports = [
+ ./common.nix
+ ];
+
+ services.xserver = {
+ displayManager = {
+ lightdm = {
+ enable = true;
+ };
+ };
+ desktopManager = {
+ xfce = {
+ enable = true;
+ };
+ };
+ };
+}
diff --git a/nix/vm/common.nix b/nix/vm/common.nix
new file mode 100644
index 0000000000..eefd7c1c03
--- /dev/null
+++ b/nix/vm/common.nix
@@ -0,0 +1,83 @@
+{pkgs, ...}: {
+ boot.loader.systemd-boot.enable = true;
+ boot.loader.efi.canTouchEfiVariables = true;
+
+ documentation.nixos.enable = false;
+
+ networking.hostName = "ghostty";
+ networking.domain = "mitchellh.com";
+
+ virtualisation.vmVariant = {
+ virtualisation.memorySize = 2048;
+ };
+
+ nix = {
+ settings = {
+ trusted-users = [
+ "root"
+ "ghostty"
+ ];
+ };
+ extraOptions = ''
+ experimental-features = nix-command flakes
+ '';
+ };
+
+ users.mutableUsers = false;
+
+ users.groups.ghostty = {};
+
+ users.users.ghostty = {
+ 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";
+ };
+ };
+
+ environment.systemPackages = [
+ pkgs.kitty
+ pkgs.fish
+ pkgs.ghostty
+ pkgs.helix
+ pkgs.neovim
+ pkgs.xterm
+ pkgs.zsh
+ ];
+
+ security.polkit = {
+ enable = true;
+ };
+
+ services.dbus = {
+ enable = true;
+ };
+
+ services.displayManager = {
+ autoLogin = {
+ user = "ghostty";
+ };
+ };
+
+ services.libinput = {
+ enable = true;
+ };
+
+ services.qemuGuest = {
+ enable = true;
+ };
+
+ services.spice-vdagentd = {
+ enable = true;
+ };
+
+ services.xserver = {
+ enable = true;
+ };
+}
diff --git a/nix/vm/create-cinnamon.nix b/nix/vm/create-cinnamon.nix
new file mode 100644
index 0000000000..a9d9e44d77
--- /dev/null
+++ b/nix/vm/create-cinnamon.nix
@@ -0,0 +1,12 @@
+{
+ system,
+ nixpkgs,
+ overlay,
+ module,
+ uid ? 1000,
+ gid ? 1000,
+}:
+import ./create.nix {
+ inherit system nixpkgs overlay module uid gid;
+ common = ./common-cinnamon.nix;
+}
diff --git a/nix/vm/create-gnome.nix b/nix/vm/create-gnome.nix
new file mode 100644
index 0000000000..bcd31f2b63
--- /dev/null
+++ b/nix/vm/create-gnome.nix
@@ -0,0 +1,12 @@
+{
+ system,
+ nixpkgs,
+ overlay,
+ module,
+ uid ? 1000,
+ gid ? 1000,
+}:
+import ./create.nix {
+ inherit system nixpkgs overlay module uid gid;
+ common = ./common-gnome.nix;
+}
diff --git a/nix/vm/create-plasma6.nix b/nix/vm/create-plasma6.nix
new file mode 100644
index 0000000000..ede5371f34
--- /dev/null
+++ b/nix/vm/create-plasma6.nix
@@ -0,0 +1,12 @@
+{
+ system,
+ nixpkgs,
+ overlay,
+ module,
+ uid ? 1000,
+ gid ? 1000,
+}:
+import ./create.nix {
+ inherit system nixpkgs overlay module uid gid;
+ common = ./common-plasma6.nix;
+}
diff --git a/nix/vm/create-xfce.nix b/nix/vm/create-xfce.nix
new file mode 100644
index 0000000000..d1789472d4
--- /dev/null
+++ b/nix/vm/create-xfce.nix
@@ -0,0 +1,12 @@
+{
+ system,
+ nixpkgs,
+ overlay,
+ module,
+ uid ? 1000,
+ gid ? 1000,
+}:
+import ./create.nix {
+ inherit system nixpkgs overlay module uid gid;
+ common = ./common-xfce.nix;
+}
diff --git a/nix/vm/create.nix b/nix/vm/create.nix
new file mode 100644
index 0000000000..f8fe8500da
--- /dev/null
+++ b/nix/vm/create.nix
@@ -0,0 +1,42 @@
+{
+ system,
+ nixpkgs,
+ overlay,
+ module,
+ common ? ./common.nix,
+ uid ? 1000,
+ gid ? 1000,
+}: let
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [
+ overlay
+ ];
+ };
+in
+ nixpkgs.lib.nixosSystem {
+ system = builtins.replaceStrings ["darwin"] ["linux"] system;
+ modules = [
+ {
+ virtualisation.vmVariant = {
+ virtualisation.host.pkgs = pkgs;
+ };
+
+ nixpkgs.overlays = [
+ overlay
+ ];
+
+ users.groups.ghostty = {
+ gid = gid;
+ };
+
+ users.users.ghostty = {
+ uid = uid;
+ };
+
+ system.stateVersion = nixpkgs.lib.trivial.release;
+ }
+ common
+ module
+ ];
+ }
diff --git a/nix/vm/wayland-cinnamon.nix b/nix/vm/wayland-cinnamon.nix
new file mode 100644
index 0000000000..531c882b64
--- /dev/null
+++ b/nix/vm/wayland-cinnamon.nix
@@ -0,0 +1,7 @@
+{...}: {
+ imports = [
+ ./common-cinnamon.nix
+ ];
+
+ services.displayManager.defaultSession = "cinnamon-wayland";
+}
diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix
new file mode 100644
index 0000000000..eb277d5d1b
--- /dev/null
+++ b/nix/vm/wayland-gnome.nix
@@ -0,0 +1,9 @@
+{...}: {
+ imports = [
+ ./common-gnome.nix
+ ];
+
+ services.displayManager = {
+ defaultSession = "gnome";
+ };
+}
diff --git a/nix/vm/wayland-plasma6.nix b/nix/vm/wayland-plasma6.nix
new file mode 100644
index 0000000000..6e5a253b89
--- /dev/null
+++ b/nix/vm/wayland-plasma6.nix
@@ -0,0 +1,6 @@
+{...}: {
+ imports = [
+ ./common-plasma6.nix
+ ];
+ services.displayManager.defaultSession = "plasma";
+}
diff --git a/nix/vm/x11-cinnamon.nix b/nix/vm/x11-cinnamon.nix
new file mode 100644
index 0000000000..636f235a2c
--- /dev/null
+++ b/nix/vm/x11-cinnamon.nix
@@ -0,0 +1,7 @@
+{...}: {
+ imports = [
+ ./common-cinnamon.nix
+ ];
+
+ services.displayManager.defaultSession = "cinnamon";
+}
diff --git a/nix/vm/x11-gnome.nix b/nix/vm/x11-gnome.nix
new file mode 100644
index 0000000000..1994aea82f
--- /dev/null
+++ b/nix/vm/x11-gnome.nix
@@ -0,0 +1,9 @@
+{...}: {
+ imports = [
+ ./common-gnome.nix
+ ];
+
+ services.displayManager = {
+ defaultSession = "gnome-xorg";
+ };
+}
diff --git a/nix/vm/x11-plasma6.nix b/nix/vm/x11-plasma6.nix
new file mode 100644
index 0000000000..7818a80ca1
--- /dev/null
+++ b/nix/vm/x11-plasma6.nix
@@ -0,0 +1,6 @@
+{...}: {
+ imports = [
+ ./common-plasma6.nix
+ ];
+ services.displayManager.defaultSession = "plasmax11";
+}
diff --git a/nix/vm/x11-xfce.nix b/nix/vm/x11-xfce.nix
new file mode 100644
index 0000000000..71eb87f2fb
--- /dev/null
+++ b/nix/vm/x11-xfce.nix
@@ -0,0 +1,7 @@
+{...}: {
+ imports = [
+ ./common-xfce.nix
+ ];
+
+ services.displayManager.defaultSession = "xfce";
+}
diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix
deleted file mode 100644
index def5a11e32..0000000000
--- a/nix/zigCacheHash.nix
+++ /dev/null
@@ -1,3 +0,0 @@
-# This file is auto-generated! check build-support/check-zig-cache-hash.sh for
-# more details.
-"sha256-2zXNHWSSWjnpW8oHu2sufT5+Ms4IKWaH6yRARQeMcxk="
diff --git a/pkg/macos/graphics/color_space.zig b/pkg/macos/graphics/color_space.zig
index 459f063029..16960591b2 100644
--- a/pkg/macos/graphics/color_space.zig
+++ b/pkg/macos/graphics/color_space.zig
@@ -18,9 +18,72 @@ pub const ColorSpace = opaque {
) orelse Allocator.Error.OutOfMemory;
}
+ pub fn createNamed(name: Name) Allocator.Error!*ColorSpace {
+ return @as(
+ ?*ColorSpace,
+ @ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))),
+ ) orelse Allocator.Error.OutOfMemory;
+ }
+
pub fn release(self: *ColorSpace) void {
c.CGColorSpaceRelease(@ptrCast(self));
}
+
+ pub const Name = enum {
+ /// This color space uses the DCI P3 primaries, a D65 white point, and
+ /// the sRGB transfer function.
+ displayP3,
+ /// The Display P3 color space with a linear transfer function and
+ /// extended-range values.
+ extendedLinearDisplayP3,
+ /// The sRGB colorimetry and non-linear transfer function are specified
+ /// in IEC 61966-2-1.
+ sRGB,
+ /// This color space has the same colorimetry as `sRGB`, but uses a
+ /// linear transfer function.
+ linearSRGB,
+ /// This color space has the same colorimetry as `sRGB`, but you can
+ /// encode component values below `0.0` and above `1.0`. Negative values
+ /// are encoded as the signed reflection of the original encoding
+ /// function, as shown in the formula below:
+ /// ```
+ /// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x))
+ /// ```
+ extendedSRGB,
+ /// This color space has the same colorimetry as `sRGB`; in addition,
+ /// you may encode component values below `0.0` and above `1.0`.
+ extendedLinearSRGB,
+ /// ...
+ genericGrayGamma2_2,
+ /// ...
+ linearGray,
+ /// This color space has the same colorimetry as `genericGrayGamma2_2`,
+ /// but you can encode component values below `0.0` and above `1.0`.
+ /// Negative values are encoded as the signed reflection of the
+ /// original encoding function, as shown in the formula below:
+ /// ```
+ /// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x))
+ /// ```
+ extendedGray,
+ /// This color space has the same colorimetry as `linearGray`; in
+ /// addition, you may encode component values below `0.0` and above `1.0`.
+ extendedLinearGray,
+
+ fn cfstring(self: Name) c.CFStringRef {
+ return switch (self) {
+ .displayP3 => c.kCGColorSpaceDisplayP3,
+ .extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3,
+ .sRGB => c.kCGColorSpaceSRGB,
+ .extendedSRGB => c.kCGColorSpaceExtendedSRGB,
+ .linearSRGB => c.kCGColorSpaceLinearSRGB,
+ .extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB,
+ .genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2,
+ .extendedGray => c.kCGColorSpaceExtendedGray,
+ .linearGray => c.kCGColorSpaceLinearGray,
+ .extendedLinearGray => c.kCGColorSpaceExtendedLinearGray,
+ };
+ }
+ };
};
test {
diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig
index 4cd1cf9f9b..a9fa5d4fe3 100644
--- a/pkg/opengl/Texture.zig
+++ b/pkg/opengl/Texture.zig
@@ -162,4 +162,26 @@ pub const Binding = struct {
data,
);
}
+
+ pub fn copySubImage2D(
+ b: Binding,
+ level: c.GLint,
+ xoffset: c.GLint,
+ yoffset: c.GLint,
+ x: c.GLint,
+ y: c.GLint,
+ width: c.GLsizei,
+ height: c.GLsizei,
+ ) !void {
+ glad.context.CopyTexSubImage2D.?(
+ @intFromEnum(b.target),
+ level,
+ xoffset,
+ yoffset,
+ x,
+ y,
+ width,
+ height
+ );
+ }
};
diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig
index 69628f582f..c07278eed3 100644
--- a/pkg/wuffs/src/jpeg.zig
+++ b/pkg/wuffs/src/jpeg.zig
@@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
- c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
+ c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status);
}
- var frame_config: c.wuffs_base__frame_config = undefined;
- {
- const status = c.wuffs_jpeg__decoder__decode_frame_config(
- decoder,
- &frame_config,
- &source_buffer,
- );
- try check(log, &status);
- }
-
{
const status = c.wuffs_jpeg__decoder__decode_frame(
decoder,
diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig
index b85e4d7474..1f37bb375a 100644
--- a/pkg/wuffs/src/png.zig
+++ b/pkg/wuffs/src/png.zig
@@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
- c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
+ c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status);
}
- var frame_config: c.wuffs_base__frame_config = undefined;
- {
- const status = c.wuffs_png__decoder__decode_frame_config(
- decoder,
- &frame_config,
- &source_buffer,
- );
- try check(log, &status);
- }
-
{
const status = c.wuffs_png__decoder__decode_frame(
decoder,
diff --git a/src/App.zig b/src/App.zig
index a6b54db232..15859d1155 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -161,7 +161,7 @@ pub fn updateConfig(self: *App, rt_app: *apprt.App, config: *const Config) !void
const applied: *const configpkg.Config = if (applied_) |*c| c else config;
// Notify the apprt that the app has changed configuration.
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.app,
.config_change,
.{ .config = applied },
@@ -180,7 +180,7 @@ pub fn addSurface(
// Since we have non-zero surfaces, we can cancel the quit timer.
// It is up to the apprt if there is a quit timer at all and if it
// should be canceled.
- rt_surface.app.performAction(
+ _ = rt_surface.app.performAction(
.app,
.quit_timer,
.stop,
@@ -214,7 +214,7 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void {
// If we have no surfaces, we can start the quit timer. It is up to the
// apprt to determine if this is necessary.
- if (self.surfaces.items.len == 0) rt_surface.app.performAction(
+ if (self.surfaces.items.len == 0) _ = rt_surface.app.performAction(
.app,
.quit_timer,
.start,
@@ -294,7 +294,7 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
break :target .app;
};
- try rt_app.performAction(
+ _ = try rt_app.performAction(
target,
.new_window,
{},
@@ -419,7 +419,7 @@ pub fn colorSchemeEvent(
// Request our configuration be reloaded because the new scheme may
// impact the colors of the app.
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.app,
.reload_config,
.{ .soft = true },
@@ -437,13 +437,13 @@ pub fn performAction(
switch (action) {
.unbind => unreachable,
.ignore => {},
- .quit => try rt_app.performAction(.app, .quit, {}),
- .new_window => try self.newWindow(rt_app, .{ .parent = null }),
- .open_config => try rt_app.performAction(.app, .open_config, {}),
- .reload_config => try rt_app.performAction(.app, .reload_config, .{}),
- .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}),
- .toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}),
- .toggle_visibility => try rt_app.performAction(.app, .toggle_visibility, {}),
+ .quit => _ = try rt_app.performAction(.app, .quit, {}),
+ .new_window => _ = try self.newWindow(rt_app, .{ .parent = null }),
+ .open_config => _ = try rt_app.performAction(.app, .open_config, {}),
+ .reload_config => _ = try rt_app.performAction(.app, .reload_config, .{}),
+ .close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}),
+ .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}),
+ .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}),
}
}
diff --git a/src/Surface.zig b/src/Surface.zig
index 4682f4fb5f..13436f9ffe 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -519,9 +519,17 @@ pub fn init(
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
+ var env_ = rt_surface.defaultTermioEnv() catch |err| env: {
+ // If an error occurs, we don't want to block surface startup.
+ log.warn("error getting env map for surface err={}", .{err});
+ break :env null;
+ };
+ errdefer if (env_) |*env| env.deinit();
+
// Initialize our IO backend
var io_exec = try termio.Exec.init(alloc, .{
.command = command,
+ .env = env_,
.shell_integration = config.@"shell-integration",
.shell_integration_features = config.@"shell-integration-features",
.working_directory = config.@"working-directory",
@@ -561,7 +569,7 @@ pub fn init(
errdefer self.io.deinit();
// Report initial cell size on surface creation
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = size.cell.width, .height = size.cell.height },
@@ -573,7 +581,7 @@ pub fn init(
const min_window_width_cells: u32 = 10;
const min_window_height_cells: u32 = 4;
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.{ .surface = self },
.size_limit,
.{
@@ -637,7 +645,7 @@ pub fn init(
size.padding.top +
size.padding.bottom;
- rt_app.performAction(
+ _ = rt_app.performAction(
.{ .surface = self },
.initial_size,
.{ .width = final_width, .height = final_height },
@@ -649,7 +657,7 @@ pub fn init(
}
if (config.title) |title| {
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
@@ -670,7 +678,7 @@ pub fn init(
break :xdg;
};
defer alloc.free(title);
- try rt_app.performAction(
+ _ = try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
@@ -823,7 +831,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We know that our title should end in 0.
const slice = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(v)), 0);
log.debug("changing title \"{s}\"", .{slice});
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = slice },
@@ -859,7 +867,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.color_change => |change| {
// Notify our apprt, but don't send a mode 2031 DSR report
// because VT sequences were used to change the color.
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.color_change,
.{
@@ -878,7 +886,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
@@ -910,7 +918,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
const str = try self.alloc.dupeZ(u8, w.slice());
defer self.alloc.free(str);
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.pwd,
.{ .pwd = str },
@@ -961,7 +969,7 @@ fn passwordInput(self: *Surface, v: bool) !void {
}
// Notify our apprt so it can do whatever it wants.
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.secure_input,
if (v) .on else .off,
@@ -1041,13 +1049,16 @@ fn mouseRefreshLinks(
pos_vp: terminal.point.Coordinate,
over_link: bool,
) !void {
+ // If the position is outside our viewport, do nothing
+ if (pos.x < 0 or pos.y < 0) return;
+
self.mouse.link_point = pos_vp;
if (try self.linkAtPos(pos)) |link| {
self.renderer_state.mouse.point = pos_vp;
self.mouse.over_link = true;
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
.pointer,
@@ -1060,7 +1071,7 @@ fn mouseRefreshLinks(
.trim = false,
});
defer self.alloc.free(str);
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = str },
@@ -1074,7 +1085,7 @@ fn mouseRefreshLinks(
log.warn("failed to get URI for OSC8 hyperlink", .{});
break :link;
};
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = uri },
@@ -1084,12 +1095,12 @@ fn mouseRefreshLinks(
try self.queueRender();
} else if (over_link) {
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
@@ -1101,7 +1112,7 @@ fn mouseRefreshLinks(
/// Called when our renderer health state changes.
fn updateRendererHealth(self: *Surface, health: renderer.Health) void {
log.warn("renderer health status change status={}", .{health});
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.renderer_health,
health,
@@ -1113,7 +1124,7 @@ fn updateRendererHealth(self: *Surface, health: renderer.Health) void {
/// This should be called anytime `config_conditional_state` changes
/// so that the apprt can reload the configuration.
fn notifyConfigConditionalState(self: *Surface) void {
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.reload_config,
.{ .soft = true },
@@ -1193,14 +1204,14 @@ pub fn updateConfig(
// If we have a title set then we update our window to have the
// newly configured title.
- if (config.title) |title| try self.rt_app.performAction(
+ if (config.title) |title| _ = try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
// Notify the window
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.config_change,
.{ .config = config },
@@ -1467,7 +1478,7 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void {
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
// Notify the window
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = size.width, .height = size.height },
@@ -1763,12 +1774,12 @@ pub fn keyCallback(
};
} else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) {
// If we have mouse reports on and we don't have shift pressed, we reset state
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
@@ -1786,7 +1797,7 @@ pub fn keyCallback(
.mods = self.mouse.mods,
.over_link = self.mouse.over_link,
.hidden = self.mouse.hidden,
- }).keyToMouseShape()) |shape| try self.rt_app.performAction(
+ }).keyToMouseShape()) |shape| _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
@@ -1911,7 +1922,7 @@ fn maybeHandleBinding(
}
// Start or continue our key sequence
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.key_sequence,
.{ .trigger = entry.key_ptr.* },
@@ -2020,7 +2031,7 @@ fn endKeySequence(
mem: KeySequenceMemory,
) void {
// Notify apprt key sequence ended
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.key_sequence,
.end,
@@ -3356,12 +3367,12 @@ pub fn cursorPosCallback(
self.mouse.link_point = null;
if (self.mouse.over_link) {
self.mouse.over_link = false;
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
@@ -3563,22 +3574,21 @@ fn dragLeftClickTriple(
const screen = &self.io.terminal.screen;
const click_pin = self.mouse.left_click_pin.?.*;
- // Get the word under our current point. If there isn't a word, do nothing.
- const word = screen.selectLine(.{ .pin = drag_pin }) orelse return;
+ // Get the line selection under our current drag point. If there isn't a
+ // line, do nothing.
+ const line = screen.selectLine(.{ .pin = drag_pin }) orelse return;
- // Get our selection to grow it. If we don't have a selection, start it now.
- // We may not have a selection if we started our dbl-click in an area
- // that had no data, then we dragged our mouse into an area with data.
- var sel = screen.selectLine(.{ .pin = click_pin }) orelse {
- try self.setSelection(word);
- return;
- };
+ // Get the selection under our click point. We first try to trim
+ // whitespace if we've selected a word. But if no word exists then
+ // we select the blank line.
+ const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
+ screen.selectLine(.{ .pin = click_pin, .whitespace = null });
- // Grow our selection
+ var sel = sel_ orelse return;
if (drag_pin.before(click_pin)) {
- sel.startPtr().* = word.start();
+ sel.startPtr().* = line.start();
} else {
- sel.endPtr().* = word.end();
+ sel.endPtr().* = line.end();
}
try self.setSelection(sel);
}
@@ -3788,7 +3798,7 @@ fn scrollToBottom(self: *Surface) !void {
fn hideMouse(self: *Surface) void {
if (self.mouse.hidden) return;
self.mouse.hidden = true;
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.hidden,
@@ -3800,7 +3810,7 @@ fn hideMouse(self: *Surface) void {
fn showMouse(self: *Surface) void {
if (!self.mouse.hidden) return;
self.mouse.hidden = false;
- self.rt_app.performAction(
+ _ = self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.visible,
@@ -4091,13 +4101,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
v,
),
- .new_tab => try self.rt_app.performAction(
+ .new_tab => return try self.rt_app.performAction(
.{ .surface = self },
.new_tab,
{},
),
- .close_tab => try self.rt_app.performAction(
+ .close_tab => return try self.rt_app.performAction(
.{ .surface = self },
.close_tab,
{},
@@ -4107,7 +4117,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.next_tab,
.last_tab,
.goto_tab,
- => |v, tag| try self.rt_app.performAction(
+ => |v, tag| return try self.rt_app.performAction(
.{ .surface = self },
.goto_tab,
switch (tag) {
@@ -4119,13 +4129,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
- .move_tab => |position| try self.rt_app.performAction(
+ .move_tab => |position| return try self.rt_app.performAction(
.{ .surface = self },
.move_tab,
.{ .amount = position },
),
- .new_split => |direction| try self.rt_app.performAction(
+ .new_split => |direction| return try self.rt_app.performAction(
.{ .surface = self },
.new_split,
switch (direction) {
@@ -4140,7 +4150,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
- .goto_split => |direction| try self.rt_app.performAction(
+ .goto_split => |direction| return try self.rt_app.performAction(
.{ .surface = self },
.goto_split,
switch (direction) {
@@ -4151,7 +4161,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
- .resize_split => |value| try self.rt_app.performAction(
+ .resize_split => |value| return try self.rt_app.performAction(
.{ .surface = self },
.resize_split,
.{
@@ -4165,19 +4175,25 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
- .equalize_splits => try self.rt_app.performAction(
+ .equalize_splits => return try self.rt_app.performAction(
.{ .surface = self },
.equalize_splits,
{},
),
- .toggle_split_zoom => try self.rt_app.performAction(
+ .toggle_split_zoom => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_split_zoom,
{},
),
- .toggle_fullscreen => try self.rt_app.performAction(
+ .toggle_maximize => return try self.rt_app.performAction(
+ .{ .surface = self },
+ .toggle_maximize,
+ {},
+ ),
+
+ .toggle_fullscreen => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_fullscreen,
switch (self.config.macos_non_native_fullscreen) {
@@ -4187,19 +4203,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
- .toggle_window_decorations => try self.rt_app.performAction(
+ .toggle_window_decorations => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_window_decorations,
{},
),
- .toggle_tab_overview => try self.rt_app.performAction(
+ .toggle_tab_overview => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_tab_overview,
{},
),
- .toggle_secure_input => try self.rt_app.performAction(
+ .toggle_secure_input => return try self.rt_app.performAction(
.{ .surface = self },
.secure_input,
.toggle,
@@ -4213,7 +4229,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}
},
- .inspector => |mode| try self.rt_app.performAction(
+ .inspector => |mode| return try self.rt_app.performAction(
.{ .surface = self },
.inspector,
switch (mode) {
@@ -4660,7 +4676,7 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const
self.app.last_notification_time = now;
self.app.last_notification_digest = new_digest;
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.desktop_notification,
.{
@@ -4680,7 +4696,7 @@ fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
/// Tell the surface to present itself to the user. This may involve raising the
/// window and switching tabs.
fn presentSurface(self: *Surface) !void {
- try self.rt_app.performAction(
+ _ = try self.rt_app.performAction(
.{ .surface = self },
.present_terminal,
{},
diff --git a/src/apprt.zig b/src/apprt.zig
index dd726b3f2e..b28ed3665c 100644
--- a/src/apprt.zig
+++ b/src/apprt.zig
@@ -13,6 +13,7 @@ const builtin = @import("builtin");
const build_config = @import("build_config.zig");
const structs = @import("apprt/structs.zig");
+const options = @import("options.zig").options;
pub const action = @import("apprt/action.zig");
pub const glfw = @import("apprt/glfw.zig");
@@ -39,15 +40,7 @@ pub const SurfaceSize = structs.SurfaceSize;
/// so that every build has exactly one application runtime implementation.
/// Note: it is very rare to use Runtime directly; most usage will use
/// Window or something.
-pub const runtime = switch (build_config.artifact) {
- .exe => switch (build_config.app_runtime) {
- .none => none,
- .glfw => glfw,
- .gtk => gtk,
- },
- .lib => embedded,
- .wasm_module => browser,
-};
+pub const runtime = options.runtime;
pub const App = runtime.App;
pub const Surface = runtime.Surface;
diff --git a/src/apprt/action.zig b/src/apprt/action.zig
index 25e1cd6407..fe2039e523 100644
--- a/src/apprt/action.zig
+++ b/src/apprt/action.zig
@@ -92,6 +92,9 @@ pub const Action = union(Key) {
/// Close all open windows.
close_all_windows,
+ /// Toggle maximized window state.
+ toggle_maximize,
+
/// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen,
@@ -231,6 +234,7 @@ pub const Action = union(Key) {
close_tab,
new_split,
close_all_windows,
+ toggle_maximize,
toggle_fullscreen,
toggle_tab_overview,
toggle_window_decorations,
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 44c4c5f20e..02bbda0d9a 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -12,6 +12,7 @@ const objc = @import("objc");
const apprt = @import("../apprt.zig");
const font = @import("../font/main.zig");
const input = @import("../input.zig");
+const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const CoreApp = @import("../App.zig");
@@ -45,7 +46,7 @@ pub const App = struct {
wakeup: *const fn (AppUD) callconv(.C) void,
/// Callback called to handle an action.
- action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.C) void,
+ action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.C) bool,
/// Read the clipboard value. The return value must be preserved
/// by the host until the next call. If there is no valid clipboard
@@ -238,6 +239,14 @@ pub const App = struct {
translate_mods,
);
+ // TODO(mitchellh): I think we can get rid of the above keymap
+ // translation code completely and defer to AppKit/Swift
+ // (for macOS) for handling all translations. The translation
+ // within libghostty is an artifact of an earlier design and
+ // it is buggy (see #5558). We should move closer to a GTK-style
+ // model of tracking composing states and preedit in the apprt
+ // and not in libghostty.
+
// If this is a dead key, then we're composing a character and
// we need to set our proper preedit state if we're targeting a
// surface.
@@ -469,13 +478,14 @@ pub const App = struct {
surface.queueInspectorRender();
}
- /// Perform a given action.
+ /// Perform a given action. Returns `true` if the action was able to be
+ /// performed, `false` otherwise.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
- ) !void {
+ ) !bool {
// Special case certain actions before they are sent to the
// embedded apprt.
self.performPreAction(target, action, value);
@@ -485,7 +495,7 @@ pub const App = struct {
action,
value,
});
- self.opts.action(
+ return self.opts.action(
self,
target.cval(),
@unionInit(apprt.Action, @tagName(action), value).cval(),
@@ -638,7 +648,7 @@ pub const Surface = struct {
.y = @floatCast(opts.scale_factor),
},
.size = .{ .width = 800, .height = 600 },
- .cursor_pos = .{ .x = 0, .y = 0 },
+ .cursor_pos = .{ .x = -1, .y = -1 },
.keymap_state = .{},
};
@@ -997,7 +1007,7 @@ pub const Surface = struct {
}
fn queueInspectorRender(self: *Surface) void {
- self.app.performAction(
+ _ = self.app.performAction(
.{ .surface = &self.core_surface },
.render_inspector,
{},
@@ -1018,6 +1028,30 @@ pub const Surface = struct {
};
}
+ pub fn defaultTermioEnv(self: *const Surface) !?std.process.EnvMap {
+ const alloc = self.app.core_app.alloc;
+ var env = try internal_os.getEnvMap(alloc);
+ errdefer env.deinit();
+
+ if (comptime builtin.target.isDarwin()) {
+ if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) {
+ env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS");
+ env.remove("__XPC_DYLD_LIBRARY_PATH");
+ env.remove("DYLD_FRAMEWORK_PATH");
+ env.remove("DYLD_INSERT_LIBRARIES");
+ env.remove("DYLD_LIBRARY_PATH");
+ env.remove("LD_LIBRARY_PATH");
+ env.remove("SECURITYSESSIONID");
+ env.remove("XPC_SERVICE_NAME");
+ }
+
+ // Remove this so that running `ghostty` within Ghostty works.
+ env.remove("GHOSTTY_MAC_APP");
+ }
+
+ return env;
+ }
+
/// The cursor position from the host directly is in screen coordinates but
/// all our interface works in pixels.
fn cursorPosToPixels(self: *const Surface, pos: apprt.CursorPos) !apprt.CursorPos {
@@ -1424,7 +1458,7 @@ pub const CAPI = struct {
/// Open the configuration.
export fn ghostty_app_open_config(v: *App) void {
- v.performAction(.app, .open_config, {}) catch |err| {
+ _ = v.performAction(.app, .open_config, {}) catch |err| {
log.err("error reloading config err={}", .{err});
return;
};
@@ -1652,7 +1686,12 @@ pub const CAPI = struct {
event: KeyEvent,
) bool {
const core_event = surface.app.coreKeyEvent(
- .{ .surface = surface },
+ // Note: this "app" target here looks like a bug, but it is
+ // intentional. coreKeyEvent uses the target only as a way to
+ // trigger preedit callbacks for keymap translation and we don't
+ // want to trigger that here. See the todo item in coreKeyEvent
+ // for a long term solution to this and removing target altogether.
+ .app,
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
@@ -1762,7 +1801,7 @@ pub const CAPI = struct {
/// Request that the surface split in the given direction.
export fn ghostty_surface_split(ptr: *Surface, direction: apprt.action.SplitDirection) void {
- ptr.app.performAction(
+ _ = ptr.app.performAction(
.{ .surface = &ptr.core_surface },
.new_split,
direction,
@@ -1777,7 +1816,7 @@ pub const CAPI = struct {
ptr: *Surface,
direction: apprt.action.GotoSplit,
) void {
- ptr.app.performAction(
+ _ = ptr.app.performAction(
.{ .surface = &ptr.core_surface },
.goto_split,
direction,
@@ -1796,7 +1835,7 @@ pub const CAPI = struct {
direction: apprt.action.ResizeSplit.Direction,
amount: u16,
) void {
- ptr.app.performAction(
+ _ = ptr.app.performAction(
.{ .surface = &ptr.core_surface },
.resize_split,
.{ .direction = direction, .amount = amount },
@@ -1808,7 +1847,7 @@ pub const CAPI = struct {
/// Equalize the size of all splits in the current window.
export fn ghostty_surface_split_equalize(ptr: *Surface) void {
- ptr.app.performAction(
+ _ = ptr.app.performAction(
.{ .surface = &ptr.core_surface },
.equalize_splits,
{},
@@ -1958,7 +1997,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
- @intCast(config.@"background-blur-radius".cval()),
+ @intCast(config.@"background-blur".cval()),
);
}
diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig
index 8094baeb86..cb034cd86f 100644
--- a/src/apprt/glfw.zig
+++ b/src/apprt/glfw.zig
@@ -147,13 +147,14 @@ pub const App = struct {
glfw.postEmptyEvent();
}
- /// Perform a given action.
+ /// Perform a given action. Returns `true` if the action was able to be
+ /// performed, `false` otherwise.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
- ) !void {
+ ) !bool {
switch (action) {
.quit => self.quit = true,
@@ -237,8 +238,14 @@ pub const App = struct {
.color_change,
.pwd,
.config_change,
- => log.info("unimplemented action={}", .{action}),
+ .toggle_maximize,
+ => {
+ log.info("unimplemented action={}", .{action});
+ return false;
+ },
}
+
+ return true;
}
/// Reload the configuration. This should return the new configuration.
@@ -873,6 +880,11 @@ pub const Surface = struct {
};
}
+ pub fn defaultTermioEnv(self: *Surface) !?std.process.EnvMap {
+ _ = self;
+ return null;
+ }
+
fn sizeCallback(window: glfw.Window, width: i32, height: i32) void {
_ = width;
_ = height;
diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig
index 70fc182e5c..f9a3ab1603 100644
--- a/src/apprt/gtk/App.zig
+++ b/src/apprt/gtk/App.zig
@@ -73,6 +73,11 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// This is set to false when the main loop should exit.
running: bool = true,
+/// If we should retry querying D-Bus for the color scheme with the deprecated
+/// Read method, instead of the recommended ReadOne method. This is kind of
+/// nasty to have as struct state but its just a byte...
+dbus_color_scheme_retry: bool = true,
+
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
@@ -141,6 +146,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
var gdk_disable: struct {
@"gles-api": bool = false,
+ /// current gtk implementation for color management is not good enough.
+ /// see: https://bugs.kde.org/show_bug.cgi?id=495647
+ /// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864
+ @"color-mgmt": bool = true,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
/// disable it.
@@ -148,6 +157,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
} = .{};
environment: {
+ if (version.runtimeAtLeast(4, 18, 0)) {
+ gdk_disable.@"color-mgmt" = false;
+ }
+
if (version.runtimeAtLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
// For the remainder of "why" see the 4.14 comment below.
@@ -494,29 +507,31 @@ pub fn terminate(self: *App) void {
self.config.deinit();
}
-/// Perform a given action.
+/// Perform a given action. Returns `true` if the action was able to be
+/// performed, `false` otherwise.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
-) !void {
+) !bool {
switch (action) {
.quit => self.quit(),
.new_window => _ = try self.newWindow(switch (target) {
.app => null,
.surface => |v| v,
}),
+ .toggle_maximize => self.toggleMaximize(target),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
.close_tab => try self.closeTab(target),
- .goto_tab => self.gotoTab(target, value),
+ .goto_tab => return self.gotoTab(target, value),
.move_tab => self.moveTab(target, value),
.new_split => try self.newSplit(target, value),
.resize_split => self.resizeSplit(target, value),
.equalize_splits => self.equalizeSplits(target),
- .goto_split => self.gotoSplit(target, value),
+ .goto_split => return self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc),
.config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value),
@@ -545,8 +560,15 @@ pub fn performAction(
.render_inspector,
.renderer_health,
.color_change,
- => log.warn("unimplemented action={}", .{action}),
+ => {
+ log.warn("unimplemented action={}", .{action});
+ return false;
+ },
}
+
+ // We can assume it was handled because all unknown/unimplemented actions
+ // are caught above.
+ return true;
}
fn newTab(_: *App, target: apprt.Target) !void {
@@ -578,29 +600,29 @@ fn closeTab(_: *App, target: apprt.Target) !void {
return;
};
- tab.window.closeTab(tab);
+ tab.closeWithConfirmation();
},
}
}
-fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void {
+fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) bool {
switch (target) {
- .app => {},
+ .app => return false,
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
- return;
+ return false;
};
- switch (tab) {
+ return switch (tab) {
.previous => window.gotoPreviousTab(v.rt_surface),
.next => window.gotoNextTab(v.rt_surface),
.last => window.gotoLastTab(),
else => window.gotoTab(@intCast(@intFromEnum(tab))),
- }
+ };
},
}
}
@@ -654,18 +676,22 @@ fn gotoSplit(
_: *const App,
target: apprt.Target,
direction: apprt.action.GotoSplit,
-) void {
+) bool {
switch (target) {
- .app => {},
+ .app => return false,
.surface => |v| {
- const s = v.rt_surface.container.split() orelse return;
+ const s = v.rt_surface.container.split() orelse return false;
const map = s.directionMap(switch (v.rt_surface.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
- const surface_ = map.get(direction) orelse return;
- if (surface_) |surface| surface.grabFocus();
+ const surface_ = map.get(direction) orelse return false;
+ if (surface_) |surface| {
+ surface.grabFocus();
+ return true;
+ }
+ return false;
},
}
}
@@ -709,6 +735,22 @@ fn controlInspector(
surface.controlInspector(mode);
}
+fn toggleMaximize(_: *App, target: apprt.Target) void {
+ switch (target) {
+ .app => {},
+ .surface => |v| {
+ const window = v.rt_surface.container.window() orelse {
+ log.info(
+ "toggleMaximize invalid for container={s}",
+ .{@tagName(v.rt_surface.container)},
+ );
+ return;
+ };
+ window.toggleMaximize();
+ },
+ }
+}
+
fn toggleFullscreen(
_: *App,
target: apprt.Target,
@@ -1254,7 +1296,8 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path;
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
- // Setup our D-Bus connection for listening to settings changes.
+ // Setup our D-Bus connection for listening to settings changes,
+ // and asynchronously request the initial color scheme
self.initDbus();
// Setup our menu items
@@ -1262,9 +1305,6 @@ pub fn run(self: *App) !void {
self.initMenu();
self.initContextMenu();
- // Setup our initial color scheme
- self.colorSchemeEvent(self.getColorScheme());
-
// On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial
// state.
@@ -1312,6 +1352,22 @@ fn initDbus(self: *App) void {
self,
null,
);
+
+ // Request the initial color scheme asynchronously.
+ c.g_dbus_connection_call(
+ dbus,
+ "org.freedesktop.portal.Desktop",
+ "/org/freedesktop/portal/desktop",
+ "org.freedesktop.portal.Settings",
+ "ReadOne",
+ c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
+ c.G_VARIANT_TYPE("(v)"),
+ c.G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ null,
+ dbusColorSchemeCallback,
+ self,
+ );
}
// This timeout function is started when no surfaces are open. It can be
@@ -1549,93 +1605,58 @@ fn gtkWindowIsActive(
core_app.focusEvent(false);
}
-/// Call a D-Bus method to determine the current color scheme. If there
-/// is any error at any point we'll log the error and return "light"
-pub fn getColorScheme(self: *App) apprt.ColorScheme {
- const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
+fn dbusColorSchemeCallback(
+ source_object: [*c]c.GObject,
+ res: ?*c.GAsyncResult,
+ ud: ?*anyopaque,
+) callconv(.C) void {
+ const self: *App = @ptrCast(@alignCast(ud.?));
+ const dbus: *c.GDBusConnection = @ptrCast(source_object);
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
- const value = c.g_dbus_connection_call_sync(
- dbus_connection,
- "org.freedesktop.portal.Desktop",
- "/org/freedesktop/portal/desktop",
- "org.freedesktop.portal.Settings",
- "ReadOne",
- c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
- c.G_VARIANT_TYPE("(v)"),
- c.G_DBUS_CALL_FLAGS_NONE,
- -1,
- null,
- &err,
- ) orelse {
- if (err) |e| {
- // If ReadOne is not yet implemented, fall back to deprecated "Read" method
- // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne”
- if (e.code == 19) {
- return self.getColorSchemeDeprecated();
+ if (c.g_dbus_connection_call_finish(dbus, res, &err)) |value| {
+ if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
+ var inner: ?*c.GVariant = null;
+ c.g_variant_get(value, "(v)", &inner);
+ defer c.g_variant_unref(inner);
+ if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
+ self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1)
+ .dark
+ else
+ .light);
+ return;
}
- // Otherwise, log the error and return .light
- log.err("unable to get current color scheme: {s}", .{e.message});
}
- return .light;
- };
- defer c.g_variant_unref(value);
-
- if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
- var inner: ?*c.GVariant = null;
- c.g_variant_get(value, "(v)", &inner);
- defer c.g_variant_unref(inner);
- if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
- return if (c.g_variant_get_uint32(inner) == 1) .dark else .light;
+ } else if (err) |e| {
+ // If ReadOne is not yet implemented, fall back to deprecated "Read" method
+ // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne”
+ if (self.dbus_color_scheme_retry and e.code == 19) {
+ self.dbus_color_scheme_retry = false;
+ c.g_dbus_connection_call(
+ dbus,
+ "org.freedesktop.portal.Desktop",
+ "/org/freedesktop/portal/desktop",
+ "org.freedesktop.portal.Settings",
+ "Read",
+ c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
+ c.G_VARIANT_TYPE("(v)"),
+ c.G_DBUS_CALL_FLAGS_NONE,
+ -1,
+ null,
+ dbusColorSchemeCallback,
+ self,
+ );
+ return;
}
- }
-
- return .light;
-}
-
-/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If
-/// there is any error at any point we'll log the error and return "light"
-fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
- const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
- var err: ?*c.GError = null;
- defer if (err) |e| c.g_error_free(e);
- const value = c.g_dbus_connection_call_sync(
- dbus_connection,
- "org.freedesktop.portal.Desktop",
- "/org/freedesktop/portal/desktop",
- "org.freedesktop.portal.Settings",
- "Read",
- c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
- c.G_VARIANT_TYPE("(v)"),
- c.G_DBUS_CALL_FLAGS_NONE,
- -1,
- null,
- &err,
- ) orelse {
- if (err) |e| log.err("Read method failed: {s}", .{e.message});
- return .light;
- };
- defer c.g_variant_unref(value);
-
- if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
- var inner: ?*c.GVariant = null;
- c.g_variant_get(value, "(v)", &inner);
- defer if (inner) |i| c.g_variant_unref(i);
-
- if (inner) |i| {
- const child = c.g_variant_get_child_value(i, 0) orelse {
- return .light;
- };
- defer c.g_variant_unref(child);
-
- const val = c.g_variant_get_uint32(child);
- return if (val == 1) .dark else .light;
- }
+ // Otherwise, log the error and return .light
+ log.warn("unable to get current color scheme: {s}", .{e.message});
}
- return .light;
+
+ // Fall back
+ self.colorSchemeEvent(.light);
}
/// This will be called by D-Bus when the style changes between light & dark.
@@ -1864,16 +1885,14 @@ fn initContextMenu(self: *App) void {
c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector");
}
- if (!self.config.@"window-decoration") {
- const section = c.g_menu_new();
- defer c.g_object_unref(section);
- const submenu = c.g_menu_new();
- defer c.g_object_unref(submenu);
+ const section = c.g_menu_new();
+ defer c.g_object_unref(section);
+ const submenu = c.g_menu_new();
+ defer c.g_object_unref(submenu);
- initMenuContent(@ptrCast(submenu));
- c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu)));
- c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
- }
+ initMenuContent(@ptrCast(submenu));
+ c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu)));
+ c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
self.context_menu = menu;
}
diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig
index c5a001f340..c4b7717ccd 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -368,10 +368,9 @@ cursor_pos: apprt.CursorPos,
inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions.
-in_keypress: bool = false,
+in_keyevent: IMKeyEvent = .false,
im_context: *c.GtkIMContext,
im_composing: bool = false,
-im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined,
im_len: u7 = 0,
@@ -379,6 +378,20 @@ im_len: u7 = 0,
/// details on what this is.
cgroup_path: ?[]const u8 = null,
+/// The state of the key event while we're doing IM composition.
+/// See gtkKeyPressed for detailed descriptions.
+pub const IMKeyEvent = enum {
+ /// Not in a key event.
+ false,
+
+ /// In a key event but im_composing was either true or false
+ /// prior to the calling IME processing. This is important to
+ /// work around different input methods calling commit and
+ /// preedit end in a different order.
+ composing,
+ not_composing,
+};
+
/// Configuration used for initializing the surface. We have to copy some
/// data since initialization is delayed with GTK (on realize).
pub const InitConfig = struct {
@@ -560,7 +573,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.font_size = font_size,
.init_config = init_config,
.size = .{ .width = 800, .height = 600 },
- .cursor_pos = .{ .x = 0, .y = 0 },
+ .cursor_pos = .{ .x = -1, .y = -1 },
.im_context = im_context,
.cgroup_path = cgroup_path,
};
@@ -634,9 +647,6 @@ fn realize(self: *Surface) !void {
try self.core_surface.setFontSize(size);
}
- // Set the initial color scheme
- try self.core_surface.colorSchemeCallback(self.app.getColorScheme());
-
// Note we're realized
self.realized = true;
}
@@ -1137,7 +1147,7 @@ pub fn setClipboardString(
c.gdk_clipboard_set_text(clipboard, val.ptr);
// We only toast if we are copying to the standard clipboard.
if (clipboard_type == .standard and
- self.app.config.@"adw-toast".@"clipboard-copy")
+ self.app.config.@"app-notifications".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
@@ -1260,10 +1270,12 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void {
return;
};
+ // Convert surface coordinate into coordinate space of the
+ // context menu's parent
var point: c.graphene_point_t = .{ .x = x, .y = y };
if (c.gtk_widget_compute_point(
self.primaryWidget(),
- @ptrCast(window.window),
+ c.gtk_widget_get_parent(@ptrCast(window.context_menu)),
&c.GRAPHENE_POINT_INIT(point.x, point.y),
@ptrCast(&point),
) == 0) {
@@ -1384,11 +1396,9 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
};
if (self.container.window()) |window| {
- if (window.winproto) |*winproto| {
- winproto.resizeEvent() catch |err| {
- log.warn("failed to notify window protocol of resize={}", .{err});
- };
- }
+ window.winproto.resizeEvent() catch |err| {
+ log.warn("failed to notify window protocol of resize={}", .{err});
+ };
}
self.resize_overlay.maybeShow();
@@ -1606,30 +1616,36 @@ fn gtkKeyReleased(
)) 1 else 0;
}
-/// Key press event. This is where we do ALL of our key handling,
-/// translation to keyboard layouts, dead key handling, etc. Key handling
-/// is complicated so this comment will explain what's going on.
+/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
/// pass that to `keyCallback`. At a low level, this is more complicated
/// than it appears because we need to construct all of this information
/// and its not given to us.
///
-/// For press events, we run the keypress through the input method context
-/// in order to determine if we're in a dead key state, completed unicode
-/// char, etc. This all happens through various callbacks: preedit, commit,
-/// etc. These inspect "in_keypress" if they have to and set some instance
-/// state.
+/// For all events, we run the GdkEvent through the input method context.
+/// This allows the input method to capture the event and trigger
+/// callbacks such as preedit, commit, etc.
+///
+/// There are a couple important aspects to the prior paragraph: we must
+/// send ALL events through the input method context. This is because
+/// input methods use both key press and key release events to determine
+/// the state of the input method. For example, fcitx uses key release
+/// events on modifiers (i.e. ctrl+shift) to switch the input method.
///
-/// We then take all of the information in order to determine if we have
+/// We set some state to note we're in a key event (self.in_keyevent)
+/// because some of the input method callbacks change behavior based on
+/// this state. For example, we don't want to send character events
+/// like "a" via the input "commit" event if we're actively processing
+/// a keypress because we'd lose access to the keycode information.
+/// However, a "commit" event may still happen outside of a keypress
+/// event from e.g. a tablet or on-screen keyboard.
+///
+/// Finally, we take all of the information in order to determine if we have
/// a unicode character or if we have to map the keyval to a code to
/// get the underlying logical key, etc.
///
-/// Finally, we can emit the keyCallback.
-///
-/// Note we ALSO have an IMContext attached directly to the widget
-/// which can emit preedit and commit callbacks. But, if we're not
-/// in a keypress, we let those automatically work.
+/// Then we can emit the keyCallback.
pub fn keyEvent(
self: *Surface,
action: input.Action,
@@ -1638,26 +1654,15 @@ pub fn keyEvent(
keycode: c.guint,
gtk_mods: c.GdkModifierType,
) bool {
+ // log.warn("GTKIM: keyEvent action={}", .{action});
const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key),
) orelse return false;
- const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
-
- // Get the unshifted unicode value of the keyval. This is used
- // by the Kitty keyboard protocol.
- const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
- @ptrCast(self.gl_area),
- event,
- keycode,
- );
-
- // We always reset our committed text when ending a keypress so that
- // future keypresses don't think we have a commit event.
- defer self.im_len = 0;
-
- // We only want to send the event through the IM context if we're a press
- if (action == .press or action == .repeat) {
+ // The block below is all related to input method handling. See the function
+ // comment for some high level details and then the comments within
+ // the block for more specifics.
+ {
// This can trigger an input method so we need to notify the im context
// where the cursor is so it can render the dropdowns in the correct
// place.
@@ -1669,41 +1674,98 @@ pub fn keyEvent(
.height = 1,
});
- // We mark that we're in a keypress event. We use this in our
- // IM commit callback to determine if we need to send a char callback
- // to the core surface or not.
- self.in_keypress = true;
- defer self.in_keypress = false;
-
- // Pass the event through the IM controller to handle dead key states.
- // Filter is true if the event was handled by the IM controller.
- const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0;
- // log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing });
-
- // If this is a dead key, then we're composing a character and
- // we need to set our proper preedit state.
- if (self.im_composing) preedit: {
- const text = self.im_buf[0..self.im_len];
- self.core_surface.preeditCallback(text) catch |err| {
- log.err("error in preedit callback err={}", .{err});
- break :preedit;
- };
-
- // If we're composing then we don't want to send the key
- // event to the core surface so we always return immediately.
- if (im_handled) return true;
- } else {
- // If we aren't composing, then we set our preedit to
- // empty no matter what.
- self.core_surface.preeditCallback(null) catch {};
-
- // If the IM handled this and we have no text, then we just
- // return because this probably just changed the input method
- // or something.
- if (im_handled and self.im_len == 0) return true;
+ // We note that we're in a keypress because we want some logic to
+ // depend on this. For example, we don't want to send character events
+ // like "a" via the input "commit" event if we're actively processing
+ // a keypress because we'd lose access to the keycode information.
+ //
+ // We have to maintain some additional state here of whether we
+ // were composing because different input methods call the callbacks
+ // in different orders. For example, ibus calls commit THEN preedit
+ // end but simple calls preedit end THEN commit.
+ self.in_keyevent = if (self.im_composing) .composing else .not_composing;
+ defer self.in_keyevent = .false;
+
+ // Pass the event through the input method which returns true if handled.
+ // Confusingly, not all events handled by the input method result
+ // in this returning true so we have to maintain some additional
+ // state about whether we were composing or not to determine if
+ // we should proceed with key encoding.
+ //
+ // Cases where the input method does not mark the event as handled:
+ //
+ // - If we change the input method via keypress while we have preedit
+ // text, the input method will commit the pending text but will not
+ // mark it as handled. We use the `.composing` state to detect
+ // this case.
+ //
+ // - If we switch input methods (i.e. via ctrl+shift with fcitx),
+ // the input method will handle the key release event but will not
+ // mark it as handled. I don't know any way to detect this case so
+ // it will result in a key event being sent to the key callback.
+ // For Kitty text encoding, this will result in modifiers being
+ // triggered despite being technically consumed. At the time of
+ // writing, both Kitty and Alacritty have the same behavior. I
+ // know of no way to fix this.
+ const im_handled = c.gtk_im_context_filter_keypress(
+ self.im_context,
+ event,
+ ) != 0;
+ // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{
+ // im_handled,
+ // self.im_len,
+ // self.im_composing,
+ // });
+
+ // If the input method handled the event, you would think we would
+ // never proceed with key encoding for Ghostty but that is not the
+ // case. Input methods will handle basic character encoding like
+ // typing "a" and we want to associate that with the key event.
+ // So we have to check additional state to determine if we exit.
+ if (im_handled) {
+ // If we are composing then we're in a preedit state and do
+ // not want to encode any keys. For example: type a deadkey
+ // such as single quote on a US international keyboard layout.
+ if (self.im_composing) return true;
+
+ // If we were composing and now we're not it means that we committed
+ // the text. We also don't want to encode a key event for this.
+ // Example: enable Japanese input method, press "konn" and then
+ // press enter. The final enter should not be encoded and "konn"
+ // (in hiragana) should be written as "こん".
+ if (self.in_keyevent == .composing) return true;
+
+ // Not composing and our input method buffer is empty. This could
+ // mean that the input method reacted to this event by activating
+ // an onscreen keyboard or something equivalent. We don't know.
+ // But the input method handled it and didn't give us text so
+ // we will just assume we should not encode this. This handles a
+ // real scenario when ibus starts the emoji input method
+ // (super+.).
+ if (self.im_len == 0) return true;
}
+
+ // At this point, for the sake of explanation of internal state:
+ // it is possible that im_len > 0 and im_composing == false. This
+ // means that we received a commit event from the input method that
+ // we want associated with the key event. This is common: its how
+ // basic character translation for simple inputs like "a" work.
}
+ // We always reset the length of the im buffer. There's only one scenario
+ // we reach this point with im_len > 0 and that's if we received a commit
+ // event from the input method. We don't want to keep that state around
+ // since we've handled it here.
+ defer self.im_len = 0;
+
+ // Get the keyvals for this event.
+ const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
+ const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
+ @ptrCast(self.gl_area),
+ event,
+ keycode,
+ );
+
// We want to get the physical unmapped key to process physical keybinds.
// (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| {
@@ -1715,6 +1777,7 @@ pub fn keyEvent(
event,
physical_key,
gtk_mods,
+ action,
&self.app.winproto,
);
@@ -1836,12 +1899,11 @@ fn gtkInputPreeditStart(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
- //log.debug("preedit start", .{});
+ // log.warn("GTKIM: preedit start", .{});
const self = userdataSelf(ud.?);
- if (!self.in_keypress) return;
- // Mark that we are now composing a string with a dead key state.
- // We'll record the string in the preedit-changed callback.
+ // Start our composing state for the input method and reset our
+ // input buffer to empty.
self.im_composing = true;
self.im_len = 0;
}
@@ -1852,52 +1914,33 @@ fn gtkInputPreeditChanged(
) callconv(.C) void {
const self = userdataSelf(ud.?);
- // If there's buffered character, send the characters directly to the surface.
- if (self.im_composing and self.im_commit_buffered) {
- defer self.im_commit_buffered = false;
- defer self.im_len = 0;
- _ = self.core_surface.keyCallback(.{
- .action = .press,
- .key = .invalid,
- .physical_key = .invalid,
- .mods = .{},
- .consumed_mods = .{},
- .composing = false,
- .utf8 = self.im_buf[0..self.im_len],
- }) catch |err| {
- log.err("error in key callback err={}", .{err});
- return;
- };
- }
-
- if (!self.in_keypress) return;
-
// Get our pre-edit string that we'll use to show the user.
var buf: [*c]u8 = undefined;
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
defer c.g_free(buf);
const str = std.mem.sliceTo(buf, 0);
- // If our string becomes empty we ignore this. This can happen after
- // a commit event when the preedit is being cleared and we don't want
- // to set im_len to zero. This is safe because preeditstart always sets
- // im_len to zero.
- if (str.len == 0) return;
-
- // Copy the preedit string into the im_buf. This is safe because
- // commit will always overwrite this.
- self.im_len = @intCast(@min(self.im_buf.len, str.len));
- @memcpy(self.im_buf[0..self.im_len], str);
+ // Update our preedit state in Ghostty core
+ // log.warn("GTKIM: preedit change str={s}", .{str});
+ self.core_surface.preeditCallback(str) catch |err| {
+ log.err("error in preedit callback err={}", .{err});
+ };
}
fn gtkInputPreeditEnd(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
- //log.debug("preedit end", .{});
+ // log.warn("GTKIM: preedit end", .{});
const self = userdataSelf(ud.?);
- if (!self.in_keypress) return;
+
+ // End our composing state for GTK, allowing us to commit the text.
self.im_composing = false;
+
+ // End our preedit state in Ghostty core
+ self.core_surface.preeditCallback(null) catch |err| {
+ log.err("error in preedit callback err={}", .{err});
+ };
}
fn gtkInputCommit(
@@ -1908,35 +1951,64 @@ fn gtkInputCommit(
const self = userdataSelf(ud.?);
const str = std.mem.sliceTo(bytes, 0);
- // If we're in a key event, then we want to buffer the commit so
- // that we can send the proper keycallback followed by the char
- // callback.
- if (self.in_keypress) {
- if (str.len <= self.im_buf.len) {
- @memcpy(self.im_buf[0..str.len], str);
- self.im_len = @intCast(str.len);
+ // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{
+ // self.im_composing,
+ // self.in_keyevent,
+ // str,
+ // });
- // If composing is done and character should be committed,
- // It should be committed in preedit callback.
- if (self.im_composing) {
- self.im_commit_buffered = true;
+ // We need to handle commit specially if we're in a key event.
+ // Specifically, GTK will send us a commit event for basic key
+ // encodings like "a" (on a US layout keyboard). We don't want
+ // to treat this as IME committed text because we want to associate
+ // it with a key event (i.e. "a" key press).
+ switch (self.in_keyevent) {
+ // If we're not in a key event then this commit is from
+ // some other source (i.e. on-screen keyboard, tablet, etc.)
+ // and we want to commit the text to the core surface.
+ .false => {},
+
+ // If we're in a composing state and in a key event then this
+ // key event is resulting in a commit of multiple keypresses
+ // and we don't want to encode it alongside the keypress.
+ .composing => {},
+
+ // If we're not composing then this commit is just a normal
+ // key encoding and we want our key event to handle it so
+ // that Ghostty can be aware of the key event alongside
+ // the text.
+ .not_composing => {
+ if (str.len > self.im_buf.len) {
+ log.warn("not enough buffer space for input method commit", .{});
+ return;
}
- // log.debug("input commit len={}", .{self.im_len});
- } else {
- log.warn("not enough buffer space for input method commit", .{});
- }
+ // Copy our committed text to the buffer
+ @memcpy(self.im_buf[0..str.len], str);
+ self.im_len = @intCast(str.len);
- return;
+ // log.debug("input commit len={}", .{self.im_len});
+ return;
+ },
}
- // This prevents staying in composing state after commit even though
- // input method has changed.
+ // If we reach this point from above it means we're composing OR
+ // not in a keypress. In either case, we want to commit the text
+ // given to us because that's what GTK is asking us to do. If we're
+ // not in a keypress it means that this commit came via a non-keyboard
+ // event (i.e. on-screen keyboard, tablet of some kind, etc.).
+
+ // Committing ends composing state
self.im_composing = false;
- // We're not in a keypress, so this was sent from an on-screen emoji
- // keyboard or something like that. Send the characters directly to
- // the surface.
+ // End our preedit state. Well-behaved input methods do this for us
+ // by triggering a preedit-end event but some do not (ibus 1.5.29).
+ self.core_surface.preeditCallback(null) catch |err| {
+ log.err("error in preedit callback err={}", .{err});
+ };
+
+ // Send the text to the core surface, associated with no key (an
+ // invalid key, which should produce no PTY encoding).
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
@@ -1946,7 +2018,7 @@ fn gtkInputCommit(
.composing = false,
.utf8 = str,
}) catch |err| {
- log.err("error in key callback err={}", .{err});
+ log.warn("error in key callback err={}", .{err});
return;
};
}
@@ -2054,7 +2126,7 @@ pub fn present(self: *Surface) void {
if (self.container.window()) |window| {
if (self.container.tab()) |tab| {
if (window.notebook.getTabPosition(tab)) |position|
- window.notebook.gotoNthTab(position);
+ _ = window.notebook.gotoNthTab(position);
}
c.gtk_window_present(window.window);
}
@@ -2182,6 +2254,25 @@ fn doPaste(self: *Surface, data: [:0]const u8) void {
};
}
+pub fn defaultTermioEnv(self: *Surface) !?std.process.EnvMap {
+ const alloc = self.app.core_app.alloc;
+ var env = try internal_os.getEnvMap(alloc);
+ errdefer env.deinit();
+
+ // Don't leak these GTK environment variables to child processes.
+ env.remove("GDK_DEBUG");
+ env.remove("GDK_DISABLE");
+ env.remove("GSK_RENDERER");
+
+ if (self.container.window()) |window| {
+ // On some window protocols we might want to add specific
+ // environment variables to subprocesses, such as WINDOWID on X11.
+ try window.winproto.addSubprocessEnv(&env);
+ }
+
+ return env;
+}
+
/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's
/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it.
fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool {
diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig
index 8f111cbc94..3daeffe769 100644
--- a/src/apprt/gtk/Window.zig
+++ b/src/apprt/gtk/Window.zig
@@ -57,7 +57,7 @@ toast_overlay: ?*c.GtkWidget,
adw_tab_overview_focus_timer: ?c.guint = null,
/// State and logic for windowing protocol for a window.
-winproto: ?winproto.Window,
+winproto: winproto.Window,
pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize
@@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void {
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
- .winproto = null,
+ .winproto = .none,
};
// Create the window
@@ -204,11 +204,8 @@ pub fn init(self: *Window, app: *App) !void {
}
_ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT);
-
- // If we are disabling decorations then disable them right away.
- if (!app.config.@"window-decoration") {
- c.gtk_window_set_decorated(gtk_window, 0);
- }
+ _ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(>kWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT);
+ _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT);
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
// need to stick the headerbar into the content box.
@@ -265,6 +262,9 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0);
c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START);
+ // If we want the window to be maximized, we do that here.
+ if (app.config.maximize) c.gtk_window_maximize(self.window);
+
// If we are in fullscreen mode, new windows start fullscreen.
if (app.config.fullscreen) c.gtk_window_fullscreen(self.window);
@@ -335,10 +335,7 @@ pub fn init(self: *Window, app: *App) !void {
.top,
.left,
.right,
- => c.gtk_box_prepend(
- @ptrCast(box),
- @ptrCast(@alignCast(tab_bar)),
- ),
+ => c.gtk_box_insert_child_after(@ptrCast(box), @ptrCast(@alignCast(tab_bar)), @ptrCast(@alignCast(self.headerbar.asWidget()))),
.bottom => c.gtk_box_append(
@ptrCast(box),
@@ -374,7 +371,11 @@ pub fn updateConfig(
self: *Window,
config: *const configpkg.Config,
) !void {
- if (self.winproto) |*v| try v.updateConfigEvent(config);
+ self.winproto.updateConfigEvent(config) catch |err| {
+ // We want to continue attempting to make the other config
+ // changes necessary so we just log the error and continue.
+ log.warn("failed to update window protocol config error={}", .{err});
+ };
// We always resync our appearance whenever the config changes.
try self.syncAppearance(config);
@@ -386,16 +387,52 @@ pub fn updateConfig(
/// TODO: Many of the initial style settings in `create` could possibly be made
/// reactive by moving them here.
pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void {
- if (config.@"background-opacity" < 1) {
- c.gtk_widget_remove_css_class(@ptrCast(self.window), "background");
- } else {
- c.gtk_widget_add_css_class(@ptrCast(self.window), "background");
+ self.winproto.syncAppearance() catch |err| {
+ log.warn("failed to sync winproto appearance error={}", .{err});
+ };
+
+ toggleCssClass(
+ @ptrCast(self.window),
+ "background",
+ config.@"background-opacity" >= 1,
+ );
+
+ // If we are disabling CSDs then disable them right away.
+ const csd_enabled = self.winproto.clientSideDecorationEnabled();
+ c.gtk_window_set_decorated(self.window, @intFromBool(csd_enabled));
+
+ // If we are not decorated then we hide the titlebar.
+ self.headerbar.setVisible(config.@"gtk-titlebar" and csd_enabled);
+
+ // Disable the title buttons (close, maximize, minimize, ...)
+ // *inside* the tab overview if CSDs are disabled.
+ // We do spare the search button, though.
+ if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
+ adwaita.enabled(&self.app.config))
+ {
+ if (self.tab_overview) |tab_overview| {
+ c.adw_tab_overview_set_show_start_title_buttons(
+ @ptrCast(tab_overview),
+ @intFromBool(csd_enabled),
+ );
+ c.adw_tab_overview_set_show_end_title_buttons(
+ @ptrCast(tab_overview),
+ @intFromBool(csd_enabled),
+ );
+ }
}
+}
- // Window protocol specific appearance updates
- if (self.winproto) |*v| v.syncAppearance() catch |err| {
- log.warn("failed to sync window protocol appearance error={}", .{err});
- };
+fn toggleCssClass(
+ widget: *c.GtkWidget,
+ class: [:0]const u8,
+ v: bool,
+) void {
+ if (v) {
+ c.gtk_widget_add_css_class(widget, class);
+ } else {
+ c.gtk_widget_remove_css_class(widget, class);
+ }
}
/// Sets up the GTK actions for the window scope. Actions are how GTK handles
@@ -435,7 +472,7 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu));
- if (self.winproto) |*v| v.deinit(self.app.core_app.alloc);
+ self.winproto.deinit(self.app.core_app.alloc);
if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer);
@@ -469,23 +506,25 @@ pub fn closeTab(self: *Window, tab: *Tab) void {
}
/// Go to the previous tab for a surface.
-pub fn gotoPreviousTab(self: *Window, surface: *Surface) void {
+pub fn gotoPreviousTab(self: *Window, surface: *Surface) bool {
const tab = surface.container.tab() orelse {
log.info("surface is not attached to a tab bar, cannot navigate", .{});
- return;
+ return false;
};
- self.notebook.gotoPreviousTab(tab);
+ if (!self.notebook.gotoPreviousTab(tab)) return false;
self.focusCurrentTab();
+ return true;
}
/// Go to the next tab for a surface.
-pub fn gotoNextTab(self: *Window, surface: *Surface) void {
+pub fn gotoNextTab(self: *Window, surface: *Surface) bool {
const tab = surface.container.tab() orelse {
log.info("surface is not attached to a tab bar, cannot navigate", .{});
- return;
+ return false;
};
- self.notebook.gotoNextTab(tab);
+ if (!self.notebook.gotoNextTab(tab)) return false;
self.focusCurrentTab();
+ return true;
}
/// Move the current tab for a surface.
@@ -497,20 +536,21 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void {
self.notebook.moveTab(tab, position);
}
-/// Go to the next tab for a surface.
-pub fn gotoLastTab(self: *Window) void {
- const max = self.notebook.nPages() -| 1;
- self.gotoTab(@intCast(max));
+/// Go to the last tab for a surface.
+pub fn gotoLastTab(self: *Window) bool {
+ const max = self.notebook.nPages();
+ return self.gotoTab(@intCast(max));
}
/// Go to the specific tab index.
-pub fn gotoTab(self: *Window, n: usize) void {
- if (n == 0) return;
+pub fn gotoTab(self: *Window, n: usize) bool {
+ if (n == 0) return false;
const max = self.notebook.nPages();
- if (max == 0) return;
- const page_idx = std.math.cast(c_int, n - 1) orelse return;
- self.notebook.gotoNthTab(@min(page_idx, max - 1));
+ if (max == 0) return false;
+ const page_idx = std.math.cast(c_int, n - 1) orelse return false;
+ if (!self.notebook.gotoNthTab(@min(page_idx, max - 1))) return false;
self.focusCurrentTab();
+ return true;
}
/// Toggle tab overview (if present)
@@ -522,6 +562,15 @@ pub fn toggleTabOverview(self: *Window) void {
}
}
+/// Toggle the maximized state for this window.
+pub fn toggleMaximize(self: *Window) void {
+ if (c.gtk_window_is_maximized(self.window) == 0) {
+ c.gtk_window_maximize(self.window);
+ } else {
+ c.gtk_window_unmaximize(self.window);
+ }
+}
+
/// Toggle fullscreen for this window.
pub fn toggleFullscreen(self: *Window) void {
const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
@@ -534,15 +583,11 @@ pub fn toggleFullscreen(self: *Window) void {
/// Toggle the window decorations for this window.
pub fn toggleWindowDecorations(self: *Window) void {
- const old_decorated = c.gtk_window_get_decorated(self.window) == 1;
- const new_decorated = !old_decorated;
- c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated));
-
- // If we have a titlebar, then we also show/hide it depending on the
- // decorated state. GTK tends to consider the titlebar part of the frame
- // and hides it with decorations, but libadwaita doesn't. This makes it
- // explicit.
- self.headerbar.setVisible(new_decorated);
+ self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") {
+ .auto, .client, .server => .none,
+ .none => .client,
+ };
+ self.updateConfig(&self.app.config) catch {};
}
/// Grabs focus on the currently selected tab.
@@ -588,22 +633,67 @@ fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
return true;
}
+fn gtkWindowNotifyMaximized(
+ _: *c.GObject,
+ _: *c.GParamSpec,
+ ud: ?*anyopaque,
+) callconv(.C) void {
+ const self = userdataSelf(ud orelse return);
+
+ // Only toggle visibility of the header bar when we're using CSDs,
+ // and actually intend on displaying the header bar
+ if (!self.winproto.clientSideDecorationEnabled()) return;
+
+ // If we aren't maximized, we should show the headerbar again
+ // if it was originally visible.
+ const maximized = c.gtk_window_is_maximized(self.window) != 0;
+ if (!maximized) {
+ self.headerbar.setVisible(self.app.config.@"gtk-titlebar");
+ return;
+ }
+
+ // If we are maximized, we should hide the headerbar if requested.
+ if (self.app.config.@"gtk-titlebar-hide-when-maximized") {
+ self.headerbar.setVisible(false);
+ }
+}
+
fn gtkWindowNotifyDecorated(
object: *c.GObject,
_: *c.GParamSpec,
- _: ?*anyopaque,
+ ud: ?*anyopaque,
) callconv(.C) void {
- if (c.gtk_window_get_decorated(@ptrCast(object)) == 1) {
- c.gtk_widget_remove_css_class(@ptrCast(object), "ssd");
- c.gtk_widget_remove_css_class(@ptrCast(object), "no-border-radius");
- } else {
- // Fix any artifacting that may occur in window corners. The .ssd CSS
- // class is defined in the GtkWindow documentation:
- // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition
- // for .ssd is provided by GTK and Adwaita.
- c.gtk_widget_add_css_class(@ptrCast(object), "ssd");
- c.gtk_widget_add_css_class(@ptrCast(object), "no-border-radius");
+ const self = userdataSelf(ud orelse return);
+ const is_decorated = c.gtk_window_get_decorated(@ptrCast(object)) == 1;
+
+ // Fix any artifacting that may occur in window corners. The .ssd CSS
+ // class is defined in the GtkWindow documentation:
+ // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition
+ // for .ssd is provided by GTK and Adwaita.
+ toggleCssClass(@ptrCast(object), "csd", is_decorated);
+ toggleCssClass(@ptrCast(object), "ssd", !is_decorated);
+ toggleCssClass(@ptrCast(object), "no-border-radius", !is_decorated);
+
+ // FIXME: This is to update the blur region offset on X11.
+ // Remove this when we move everything related to window appearance
+ // to `syncAppearance` for Ghostty 1.2.
+ self.winproto.syncAppearance() catch {};
+}
+
+fn gtkWindowNotifyFullscreened(
+ object: *c.GObject,
+ _: *c.GParamSpec,
+ ud: ?*anyopaque,
+) callconv(.C) void {
+ const self = userdataSelf(ud orelse return);
+ const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0;
+ if (!fullscreened) {
+ const csd_enabled = self.winproto.clientSideDecorationEnabled();
+ self.headerbar.setVisible(self.app.config.@"gtk-titlebar" and csd_enabled);
+ return;
}
+
+ self.headerbar.setVisible(false);
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig
index 4dc8ea57fd..5bd32edfea 100644
--- a/src/apprt/gtk/c.zig
+++ b/src/apprt/gtk/c.zig
@@ -4,7 +4,7 @@ const build_options = @import("build_options");
pub const c = @cImport({
@cInclude("gtk/gtk.h");
if (build_options.adwaita) {
- @cInclude("libadwaita-1/adwaita.h");
+ @cInclude("adwaita.h");
}
if (build_options.x11) {
diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig
index 2b47ea4b73..0f7f15bf8a 100644
--- a/src/apprt/gtk/headerbar.zig
+++ b/src/apprt/gtk/headerbar.zig
@@ -18,9 +18,6 @@ pub const HeaderBar = union(enum) {
} else {
HeaderBarGtk.init(self);
}
-
- if (!window.app.config.@"gtk-titlebar" or !window.app.config.@"window-decoration")
- self.setVisible(false);
}
pub fn setVisible(self: HeaderBar, visible: bool) void {
diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig
index c0d6222074..1ae23e6d91 100644
--- a/src/apprt/gtk/headerbar_adw.zig
+++ b/src/apprt/gtk/headerbar_adw.zig
@@ -65,6 +65,7 @@ pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void {
}
pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void {
+ c.gtk_window_set_title(self.window.window, title);
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_window_title_set_title(self.title, title);
}
diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig
index 40c9ca9a4b..60f12edca0 100644
--- a/src/apprt/gtk/key.zig
+++ b/src/apprt/gtk/key.zig
@@ -108,6 +108,7 @@ pub fn eventMods(
event: *c.GdkEvent,
physical_key: input.Key,
gtk_mods: c.GdkModifierType,
+ action: input.Action,
app_winproto: *winproto.App,
) input.Mods {
const device = c.gdk_event_get_device(event);
@@ -115,15 +116,55 @@ pub fn eventMods(
var mods = app_winproto.eventMods(device, gtk_mods);
mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1;
+ // We use the physical key to determine sided modifiers. As
+ // far as I can tell there's no other way to reliably determine
+ // this.
+ //
+ // We also set the main modifier to true if either side is true,
+ // since on both X11/Wayland, GTK doesn't set the main modifier
+ // if only the modifier key is pressed, but our core logic
+ // relies on it.
switch (physical_key) {
- .left_shift => mods.sides.shift = .left,
- .right_shift => mods.sides.shift = .right,
- .left_control => mods.sides.ctrl = .left,
- .right_control => mods.sides.ctrl = .right,
- .left_alt => mods.sides.alt = .left,
- .right_alt => mods.sides.alt = .right,
- .left_super => mods.sides.super = .left,
- .right_super => mods.sides.super = .right,
+ .left_shift => {
+ mods.shift = action != .release;
+ mods.sides.shift = .left;
+ },
+
+ .right_shift => {
+ mods.shift = action != .release;
+ mods.sides.shift = .right;
+ },
+
+ .left_control => {
+ mods.ctrl = action != .release;
+ mods.sides.ctrl = .left;
+ },
+
+ .right_control => {
+ mods.ctrl = action != .release;
+ mods.sides.ctrl = .right;
+ },
+
+ .left_alt => {
+ mods.alt = action != .release;
+ mods.sides.alt = .left;
+ },
+
+ .right_alt => {
+ mods.alt = action != .release;
+ mods.sides.alt = .right;
+ },
+
+ .left_super => {
+ mods.super = action != .release;
+ mods.sides.super = .left;
+ },
+
+ .right_super => {
+ mods.super = action != .release;
+ mods.sides.super = .right;
+ },
+
else => {},
}
diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig
index 4676c2529f..548f2acafe 100644
--- a/src/apprt/gtk/notebook.zig
+++ b/src/apprt/gtk/notebook.zig
@@ -59,11 +59,14 @@ pub const Notebook = union(enum) {
};
}
- pub fn gotoNthTab(self: *Notebook, position: c_int) void {
+ pub fn gotoNthTab(self: *Notebook, position: c_int) bool {
+ const current_page_ = self.currentPage();
+ if (current_page_) |current_page| if (current_page == position) return false;
switch (self.*) {
.adw => |*adw| adw.gotoNthTab(position),
.gtk => |*gtk| gtk.gotoNthTab(position),
}
+ return true;
}
pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int {
@@ -73,8 +76,8 @@ pub const Notebook = union(enum) {
};
}
- pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void {
- const page_idx = self.getTabPosition(tab) orelse return;
+ pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) bool {
+ const page_idx = self.getTabPosition(tab) orelse return false;
// The next index is the previous or we wrap around.
const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: {
@@ -83,19 +86,21 @@ pub const Notebook = union(enum) {
};
// Do nothing if we have one tab
- if (next_idx == page_idx) return;
+ if (next_idx == page_idx) return false;
- self.gotoNthTab(next_idx);
+ return self.gotoNthTab(next_idx);
}
- pub fn gotoNextTab(self: *Notebook, tab: *Tab) void {
- const page_idx = self.getTabPosition(tab) orelse return;
+ pub fn gotoNextTab(self: *Notebook, tab: *Tab) bool {
+ const page_idx = self.getTabPosition(tab) orelse return false;
const max = self.nPages() -| 1;
const next_idx = if (page_idx < max) page_idx + 1 else 0;
- if (next_idx == page_idx) return;
- self.gotoNthTab(next_idx);
+ // Do nothing if we have one tab
+ if (next_idx == page_idx) return false;
+
+ return self.gotoNthTab(next_idx);
}
pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void {
diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig
index cb873fe013..c752ee6927 100644
--- a/src/apprt/gtk/winproto.zig
+++ b/src/apprt/gtk/winproto.zig
@@ -62,7 +62,7 @@ pub const App = union(Protocol) {
/// Per-Window state for the underlying windowing protocol.
///
-/// In both X and Wayland, the terminology used is "Surface" and this is
+/// In Wayland, the terminology used is "Surface" and for it, this is
/// really "Surface"-specific state. But Ghostty uses the term "Surface"
/// heavily to mean something completely different, so we use "Window" here
/// to better match what it generally maps to in the Ghostty codebase.
@@ -125,4 +125,16 @@ pub const Window = union(Protocol) {
inline else => |*v| try v.syncAppearance(),
}
}
+
+ pub fn clientSideDecorationEnabled(self: Window) bool {
+ return switch (self) {
+ inline else => |v| v.clientSideDecorationEnabled(),
+ };
+ }
+
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ switch (self.*) {
+ inline else => |*v| try v.addSubprocessEnv(env),
+ }
+ }
};
diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig
index 14f3dc6a7f..cb1c0e9ebb 100644
--- a/src/apprt/gtk/winproto/noop.zig
+++ b/src/apprt/gtk/winproto/noop.zig
@@ -53,4 +53,14 @@ pub const Window = struct {
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(_: *Window) !void {}
+
+ /// This returns true if CSD is enabled for this window. This
+ /// should be the actual present state of the window, not the
+ /// desired state.
+ pub fn clientSideDecorationEnabled(self: Window) bool {
+ _ = self;
+ return true;
+ }
+
+ pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {}
};
diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig
index 3f7ad00680..f2ef17d73a 100644
--- a/src/apprt/gtk/winproto/wayland.zig
+++ b/src/apprt/gtk/winproto/wayland.zig
@@ -18,6 +18,12 @@ pub const App = struct {
const Context = struct {
kde_blur_manager: ?*org.KdeKwinBlurManager = null,
+
+ // FIXME: replace with `zxdg_decoration_v1` once GTK merges
+ // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
+ kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null,
+
+ default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null,
};
pub fn init(
@@ -53,6 +59,12 @@ pub const App = struct {
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
+ if (context.kde_decoration_manager != null) {
+ // FIXME: Roundtrip again because we have to wait for the decoration
+ // manager to respond with the preferred default mode. Ew.
+ if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
+ }
+
return .{
.display = display,
.context = context,
@@ -78,17 +90,22 @@ pub const App = struct {
) void {
switch (event) {
// https://wayland.app/protocols/wayland#wl_registry:event:global
- .global => |global| global: {
+ .global => |global| {
log.debug("wl_registry.global: interface={s}", .{global.interface});
if (registryBind(
org.KdeKwinBlurManager,
registry,
global,
- 1,
)) |blur_manager| {
context.kde_blur_manager = blur_manager;
- break :global;
+ } else if (registryBind(
+ org.KdeKwinServerDecorationManager,
+ registry,
+ global,
+ )) |deco_manager| {
+ context.kde_decoration_manager = deco_manager;
+ deco_manager.setListener(*Context, decoManagerListener, context);
}
},
@@ -97,11 +114,16 @@ pub const App = struct {
}
}
+ /// Bind a Wayland interface to a global object. Returns non-null
+ /// if the binding was successful, otherwise null.
+ ///
+ /// The type T is the Wayland interface type that we're requesting.
+ /// This function will verify that the global object is the correct
+ /// interface and version before binding.
fn registryBind(
comptime T: type,
registry: *wl.Registry,
global: anytype,
- version: u32,
) ?*T {
if (std.mem.orderZ(
u8,
@@ -109,7 +131,7 @@ pub const App = struct {
T.interface.name,
) != .eq) return null;
- return registry.bind(global.name, T, version) catch |err| {
+ return registry.bind(global.name, T, T.generated_version) catch |err| {
log.warn("error binding interface {s} error={}", .{
global.interface,
err,
@@ -117,6 +139,18 @@ pub const App = struct {
return null;
};
}
+
+ fn decoManagerListener(
+ _: *org.KdeKwinServerDecorationManager,
+ event: org.KdeKwinServerDecorationManager.Event,
+ context: *Context,
+ ) void {
+ switch (event) {
+ .default_mode => |mode| {
+ context.default_deco_mode = @enumFromInt(mode.mode);
+ },
+ }
+ }
};
/// Per-window (wl_surface) state for the Wayland protocol.
@@ -130,14 +164,20 @@ pub const Window = struct {
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
- blur_token: ?*org.KdeKwinBlur = null,
+ blur_token: ?*org.KdeKwinBlur,
+
+ /// Object that controls the decoration mode (client/server/auto)
+ /// of the window.
+ decoration: ?*org.KdeKwinServerDecoration,
const DerivedConfig = struct {
blur: bool,
+ window_decoration: Config.WindowDecoration,
pub fn init(config: *const Config) DerivedConfig {
return .{
- .blur = config.@"background-blur-radius".enabled(),
+ .blur = config.@"background-blur".enabled(),
+ .window_decoration = config.@"window-decoration",
};
}
};
@@ -165,19 +205,41 @@ pub const Window = struct {
gdk_surface,
) orelse return error.NoWaylandSurface);
+ // Get our decoration object so we can control the
+ // CSD vs SSD status of this surface.
+ const deco: ?*org.KdeKwinServerDecoration = deco: {
+ const mgr = app.context.kde_decoration_manager orelse
+ break :deco null;
+
+ const deco: *org.KdeKwinServerDecoration = mgr.create(
+ wl_surface,
+ ) catch |err| {
+ log.warn("could not create decoration object={}", .{err});
+ break :deco null;
+ };
+
+ break :deco deco;
+ };
+
return .{
.config = DerivedConfig.init(config),
.surface = wl_surface,
.app_context = app.context,
+ .blur_token = null,
+ .decoration = deco,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = alloc;
if (self.blur_token) |blur| blur.release();
+ if (self.decoration) |deco| deco.release();
}
- pub fn updateConfigEvent(self: *Window, config: *const Config) !void {
+ pub fn updateConfigEvent(
+ self: *Window,
+ config: *const Config,
+ ) !void {
self.config = DerivedConfig.init(config);
}
@@ -185,6 +247,24 @@ pub const Window = struct {
pub fn syncAppearance(self: *Window) !void {
try self.syncBlur();
+ try self.syncDecoration();
+ }
+
+ pub fn clientSideDecorationEnabled(self: Window) bool {
+ return switch (self.getDecorationMode()) {
+ .Client => true,
+ // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs.
+ // However, if we do not support SSDs (e.g. GNOME) then we should enable
+ // CSDs even if the user prefers SSDs.
+ .Server => if (self.app_context.kde_decoration_manager) |_| false else true,
+ .None => false,
+ else => unreachable,
+ };
+ }
+
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ _ = self;
+ _ = env;
}
/// Update the blur state of the window.
@@ -208,4 +288,21 @@ pub const Window = struct {
}
}
}
+
+ fn syncDecoration(self: *Window) !void {
+ const deco = self.decoration orelse return;
+
+ // The protocol requests uint instead of enum so we have
+ // to convert it.
+ deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode())));
+ }
+
+ fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode {
+ return switch (self.config.window_decoration) {
+ .auto => self.app_context.default_deco_mode orelse .Client,
+ .client => .Client,
+ .server => .Server,
+ .none => .None,
+ };
+ }
};
diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig
index 4eac9cdf3a..6b60b0edf8 100644
--- a/src/apprt/gtk/winproto/x11.zig
+++ b/src/apprt/gtk/winproto/x11.zig
@@ -13,7 +13,7 @@ const log = std.log.scoped(.gtk_x11);
pub const App = struct {
display: *c.Display,
base_event_code: c_int,
- kde_blur_atom: c.Atom,
+ atoms: Atoms,
pub fn init(
alloc: Allocator,
@@ -95,10 +95,7 @@ pub const App = struct {
return .{
.display = display,
.base_event_code = base_event_code,
- .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(
- gdk_display,
- "_KDE_NET_WM_BLUR_BEHIND_REGION",
- ),
+ .atoms = Atoms.init(gdk_display),
};
}
@@ -154,23 +151,27 @@ pub const App = struct {
pub const Window = struct {
app: *App,
+ alloc: Allocator,
config: DerivedConfig,
window: c.Window,
gtk_window: *c.GtkWindow,
- blur_region: Region,
+
+ blur_region: Region = .{},
const DerivedConfig = struct {
blur: bool,
+ window_decoration: Config.WindowDecoration,
pub fn init(config: *const Config) DerivedConfig {
return .{
- .blur = config.@"background-blur-radius".enabled(),
+ .blur = config.@"background-blur".enabled(),
+ .window_decoration = config.@"window-decoration",
};
}
};
pub fn init(
- _: Allocator,
+ alloc: Allocator,
app: *App,
gtk_window: *c.GtkWindow,
config: *const Config,
@@ -185,34 +186,12 @@ pub const Window = struct {
c.gdk_x11_surface_get_type(),
) == 0) return error.NotX11Surface;
- const blur_region: Region = blur: {
- if ((comptime !adwaita.versionAtLeast(0, 0, 0)) or
- !adwaita.enabled(config)) break :blur .{};
-
- // NOTE(pluiedev): CSDs are a f--king mistake.
- // Please, GNOME, stop this nonsense of making a window ~30% bigger
- // internally than how they really are just for your shadows and
- // rounded corners and all that fluff. Please. I beg of you.
- var x: f64 = 0;
- var y: f64 = 0;
- c.gtk_native_get_surface_transform(
- @ptrCast(gtk_window),
- &x,
- &y,
- );
-
- break :blur .{
- .x = @intFromFloat(x),
- .y = @intFromFloat(y),
- };
- };
-
return .{
.app = app,
+ .alloc = alloc,
.config = DerivedConfig.init(config),
.window = c.gdk_x11_surface_get_xid(surface),
.gtk_window = gtk_window,
- .blur_region = blur_region,
};
}
@@ -236,7 +215,37 @@ pub const Window = struct {
}
pub fn syncAppearance(self: *Window) !void {
- try self.syncBlur();
+ self.blur_region = blur: {
+ // NOTE(pluiedev): CSDs are a f--king mistake.
+ // Please, GNOME, stop this nonsense of making a window ~30% bigger
+ // internally than how they really are just for your shadows and
+ // rounded corners and all that fluff. Please. I beg of you.
+ var x: f64 = 0;
+ var y: f64 = 0;
+ c.gtk_native_get_surface_transform(
+ @ptrCast(self.gtk_window),
+ &x,
+ &y,
+ );
+
+ break :blur .{
+ .x = @intFromFloat(x),
+ .y = @intFromFloat(y),
+ };
+ };
+ self.syncBlur() catch |err| {
+ log.err("failed to synchronize blur={}", .{err});
+ };
+ self.syncDecorations() catch |err| {
+ log.err("failed to synchronize decorations={}", .{err});
+ };
+ }
+
+ pub fn clientSideDecorationEnabled(self: Window) bool {
+ return switch (self.config.window_decoration) {
+ .auto, .client => true,
+ .server, .none => false,
+ };
}
fn syncBlur(self: *Window) !void {
@@ -256,33 +265,199 @@ pub const Window = struct {
});
if (blur) {
- _ = c.XChangeProperty(
- self.app.display,
- self.window,
- self.app.kde_blur_atom,
+ try self.changeProperty(
+ Region,
+ self.app.atoms.kde_blur,
c.XA_CARDINAL,
- // Despite what you might think, the "32" here does NOT mean
- // that the data should be in u32s. Instead, they should be
- // c_longs, which on any 64-bit architecture would be obviously
- // 64 bits. WTF?!
- 32,
- c.PropModeReplace,
- // SAFETY: Region is an extern struct that has the same
- // representation of 4 c_longs put next to each other.
- // Therefore, reinterpretation should be safe.
- // We don't have to care about endianness either since
- // Xlib converts it to network byte order for us.
- @ptrCast(std.mem.asBytes(&self.blur_region)),
- 4,
+ ._32,
+ .{ .mode = .replace },
+ &self.blur_region,
);
} else {
- _ = c.XDeleteProperty(
- self.app.display,
- self.window,
- self.app.kde_blur_atom,
- );
+ try self.deleteProperty(self.app.atoms.kde_blur);
}
}
+
+ fn syncDecorations(self: *Window) !void {
+ var hints: MotifWMHints = .{};
+
+ self.getWindowProperty(
+ MotifWMHints,
+ self.app.atoms.motif_wm_hints,
+ self.app.atoms.motif_wm_hints,
+ ._32,
+ .{},
+ &hints,
+ ) catch |err| switch (err) {
+ // motif_wm_hints is already initialized, so this is fine
+ error.PropertyNotFound => {},
+
+ error.RequestFailed,
+ error.PropertyTypeMismatch,
+ error.PropertyFormatMismatch,
+ => return err,
+ };
+
+ hints.flags.decorations = true;
+ hints.decorations.all = switch (self.config.window_decoration) {
+ .server => true,
+ .auto, .client, .none => false,
+ };
+
+ try self.changeProperty(
+ MotifWMHints,
+ self.app.atoms.motif_wm_hints,
+ self.app.atoms.motif_wm_hints,
+ ._32,
+ .{ .mode = .replace },
+ &hints,
+ );
+ }
+
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ var buf: [64]u8 = undefined;
+ const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window});
+
+ try env.put("WINDOWID", window_id);
+ }
+
+ fn getWindowProperty(
+ self: *Window,
+ comptime T: type,
+ name: c.Atom,
+ typ: c.Atom,
+ comptime format: PropertyFormat,
+ options: struct {
+ offset: c_long = 0,
+ length: c_long = std.math.maxInt(c_long),
+ delete: bool = false,
+ },
+ result: *T,
+ ) GetWindowPropertyError!void {
+ // FIXME: Maybe we should switch to libxcb one day.
+ // Sounds like a much better idea than whatever this is
+ var actual_type_return: c.Atom = undefined;
+ var actual_format_return: c_int = undefined;
+ var nitems_return: c_ulong = undefined;
+ var bytes_after_return: c_ulong = undefined;
+ var prop_return: ?format.bufferType() = null;
+
+ const code = c.XGetWindowProperty(
+ self.app.display,
+ self.window,
+ name,
+ options.offset,
+ options.length,
+ @intFromBool(options.delete),
+ typ,
+ &actual_type_return,
+ &actual_format_return,
+ &nitems_return,
+ &bytes_after_return,
+ &prop_return,
+ );
+ if (code != c.Success) return error.RequestFailed;
+
+ if (actual_type_return == c.None) return error.PropertyNotFound;
+ if (typ != actual_type_return) return error.PropertyTypeMismatch;
+ if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch;
+
+ const data_ptr: *T = @ptrCast(prop_return);
+ result.* = data_ptr.*;
+ _ = c.XFree(prop_return);
+ }
+
+ fn changeProperty(
+ self: *Window,
+ comptime T: type,
+ name: c.Atom,
+ typ: c.Atom,
+ comptime format: PropertyFormat,
+ options: struct {
+ mode: PropertyChangeMode,
+ },
+ value: *T,
+ ) X11Error!void {
+ const data: format.bufferType() = @ptrCast(value);
+
+ const status = c.XChangeProperty(
+ self.app.display,
+ self.window,
+ name,
+ typ,
+ @intFromEnum(format),
+ @intFromEnum(options.mode),
+ data,
+ @divExact(@sizeOf(T), @sizeOf(format.elemType())),
+ );
+
+ // For some godforsaken reason Xlib alternates between
+ // error values (0 = success) and booleans (1 = success), and they look exactly
+ // the same in the signature (just `int`, since Xlib is written in C89)...
+ if (status == 0) return error.RequestFailed;
+ }
+
+ fn deleteProperty(self: *Window, name: c.Atom) X11Error!void {
+ const status = c.XDeleteProperty(self.app.display, self.window, name);
+ if (status == 0) return error.RequestFailed;
+ }
+};
+
+const X11Error = error{
+ RequestFailed,
+};
+
+const GetWindowPropertyError = X11Error || error{
+ PropertyNotFound,
+ PropertyTypeMismatch,
+ PropertyFormatMismatch,
+};
+
+const Atoms = struct {
+ kde_blur: c.Atom,
+ motif_wm_hints: c.Atom,
+
+ fn init(display: *c.GdkDisplay) Atoms {
+ return .{
+ .kde_blur = c.gdk_x11_get_xatom_by_name_for_display(
+ display,
+ "_KDE_NET_WM_BLUR_BEHIND_REGION",
+ ),
+ .motif_wm_hints = c.gdk_x11_get_xatom_by_name_for_display(
+ display,
+ "_MOTIF_WM_HINTS",
+ ),
+ };
+ }
+};
+
+const PropertyChangeMode = enum(c_int) {
+ replace = c.PropModeReplace,
+ prepend = c.PropModePrepend,
+ append = c.PropModeAppend,
+};
+
+const PropertyFormat = enum(c_int) {
+ _8 = 8,
+ _16 = 16,
+ _32 = 32,
+
+ fn elemType(comptime self: PropertyFormat) type {
+ return switch (self) {
+ ._8 => c_char,
+ ._16 => c_int,
+ ._32 => c_long,
+ };
+ }
+
+ fn bufferType(comptime self: PropertyFormat) type {
+ // The buffer type has to be a multi-pointer to bytes
+ // *aligned to the element type* (very important,
+ // otherwise you'll read garbage!)
+ //
+ // I know this is really ugly. X11 is ugly. I consider it apropos.
+ return [*]align(@alignOf(self.elemType())) u8;
+ }
};
const Region = extern struct {
@@ -291,3 +466,23 @@ const Region = extern struct {
width: c_long = 0,
height: c_long = 0,
};
+
+// See Xm/MwmUtil.h, packaged with the Motif Window Manager
+const MotifWMHints = extern struct {
+ flags: packed struct(c_ulong) {
+ _pad: u1 = 0,
+ decorations: bool = false,
+
+ // We don't really care about the other flags
+ _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0,
+ } = .{},
+ functions: c_ulong = 0,
+ decorations: packed struct(c_ulong) {
+ all: bool = false,
+
+ // We don't really care about the other flags
+ _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0,
+ } = .{},
+ input_mode: c_long = 0,
+ status: c_ulong = 0,
+};
diff --git a/src/build/Config.zig b/src/build/Config.zig
index 71dffce4ab..0ff0fc914f 100644
--- a/src/build/Config.zig
+++ b/src/build/Config.zig
@@ -19,7 +19,7 @@ const GitVersion = @import("GitVersion.zig");
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release.
-const app_version: std.SemanticVersion = .{ .major = 1, .minor = 0, .patch = 2 };
+const app_version: std.SemanticVersion = .{ .major = 1, .minor = 1, .patch = 1 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,
@@ -55,6 +55,8 @@ emit_helpgen: bool = false,
emit_docs: bool = false,
emit_webdata: bool = false,
emit_xcframework: bool = false,
+emit_terminfo: bool = false,
+emit_termcap: bool = false,
/// Environmental properties
env: std.process.EnvMap,
@@ -306,6 +308,27 @@ pub fn init(b: *std.Build) !Config {
break :emit_docs path != null;
};
+ config.emit_terminfo = b.option(
+ bool,
+ "emit-terminfo",
+ "Install Ghostty terminfo source file",
+ ) orelse switch (target.result.os.tag) {
+ .windows => true,
+ else => switch (optimize) {
+ .Debug => true,
+ .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
+ },
+ };
+
+ config.emit_termcap = b.option(
+ bool,
+ "emit-termcap",
+ "Install Ghostty termcap file",
+ ) orelse switch (optimize) {
+ .Debug => true,
+ .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
+ };
+
config.emit_webdata = b.option(
bool,
"emit-webdata",
@@ -486,6 +509,7 @@ pub const ExeEntrypoint = enum {
mdgen_ghostty_5,
webgen_config,
webgen_actions,
+ webgen_commands,
bench_parser,
bench_stream,
bench_codepoint_width,
diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig
index cae907ec2d..912308e46c 100644
--- a/src/build/GhosttyResources.zig
+++ b/src/build/GhosttyResources.zig
@@ -23,9 +23,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Write it
var wf = b.addWriteFiles();
- const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items);
- const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo");
- try steps.append(&src_install.step);
+ const source = wf.add("ghostty.terminfo", str.items);
+
+ if (cfg.emit_terminfo) {
+ const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo");
+ try steps.append(&source_install.step);
+ }
// Windows doesn't have the binaries below.
if (cfg.target.result.os.tag == .windows) break :terminfo;
@@ -33,10 +36,10 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Convert to termcap source format if thats helpful to people and
// install it. The resulting value here is the termcap source in case
// that is used for other commands.
- {
+ if (cfg.emit_termcap) {
const run_step = RunStep.create(b, "infotocap");
run_step.addArg("infotocap");
- run_step.addFileArg(src_source);
+ run_step.addFileArg(source);
const out_source = run_step.captureStdOut();
_ = run_step.captureStdErr(); // so we don't see stderr
@@ -49,23 +52,29 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const run_step = RunStep.create(b, "tic");
run_step.addArgs(&.{ "tic", "-x", "-o" });
const path = run_step.addOutputFileArg("terminfo");
- run_step.addFileArg(src_source);
+ run_step.addFileArg(source);
_ = run_step.captureStdErr(); // so we don't see stderr
- // Depend on the terminfo source install step so that Zig build
- // creates the "share" directory for us.
- run_step.step.dependOn(&src_install.step);
-
- {
- // Use cp -R instead of Step.InstallDir because we need to preserve
- // symlinks in the terminfo database. Zig's InstallDir step doesn't
- // handle symlinks correctly yet.
- const copy_step = RunStep.create(b, "copy terminfo db");
- copy_step.addArgs(&.{ "cp", "-R" });
- copy_step.addFileArg(path);
- copy_step.addArg(b.fmt("{s}/share", .{b.install_path}));
- try steps.append(©_step.step);
+ // Ensure that `share/terminfo` is a directory, otherwise the `cp
+ // -R` will create a file named `share/terminfo`
+ const mkdir_step = RunStep.create(b, "make share/terminfo directory");
+ switch (cfg.target.result.os.tag) {
+ // windows mkdir shouldn't need "-p"
+ .windows => mkdir_step.addArgs(&.{"mkdir"}),
+ else => mkdir_step.addArgs(&.{ "mkdir", "-p" }),
}
+ mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path}));
+ try steps.append(&mkdir_step.step);
+
+ // Use cp -R instead of Step.InstallDir because we need to preserve
+ // symlinks in the terminfo database. Zig's InstallDir step doesn't
+ // handle symlinks correctly yet.
+ const copy_step = RunStep.create(b, "copy terminfo db");
+ copy_step.addArgs(&.{ "cp", "-R" });
+ copy_step.addFileArg(path);
+ copy_step.addArg(b.fmt("{s}/share", .{b.install_path}));
+ copy_step.step.dependOn(&mkdir_step.step);
+ try steps.append(©_step.step);
}
}
@@ -200,10 +209,11 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
"share/kio/servicemenus/com.mitchellh.ghostty.desktop",
).step);
- // Right click menu action for Nautilus
+ // Right click menu action for Nautilus. Note that this _must_ be named
+ // `ghostty.py`. Using the full app id causes problems (see #5468).
try steps.append(&b.addInstallFile(
b.path("dist/linux/ghostty_nautilus.py"),
- "share/nautilus-python/extensions/com.mitchellh.ghostty.py",
+ "share/nautilus-python/extensions/ghostty.py",
).step);
// Various icons that our application can use, including the icon
diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig
index 6e0acaf173..860feb705a 100644
--- a/src/build/GhosttyWebdata.zig
+++ b/src/build/GhosttyWebdata.zig
@@ -73,6 +73,35 @@ pub fn init(
).step);
}
+ {
+ const webgen_commands = b.addExecutable(.{
+ .name = "webgen_commands",
+ .root_source_file = b.path("src/main.zig"),
+ .target = b.host,
+ });
+ deps.help_strings.addImport(webgen_commands);
+
+ {
+ const buildconfig = config: {
+ var copy = deps.config.*;
+ copy.exe_entrypoint = .webgen_commands;
+ break :config copy;
+ };
+
+ const options = b.addOptions();
+ try buildconfig.addOptions(options);
+ webgen_commands.root_module.addOptions("build_options", options);
+ }
+
+ const webgen_commands_step = b.addRunArtifact(webgen_commands);
+ const webgen_commands_out = webgen_commands_step.captureStdOut();
+
+ try steps.append(&b.addInstallFile(
+ webgen_commands_out,
+ "share/ghostty/webdata/commands.mdx",
+ ).step);
+ }
+
return .{ .steps = steps.items };
}
diff --git a/src/build/HelpStrings.zig b/src/build/HelpStrings.zig
index 0244670ccc..bf36f66943 100644
--- a/src/build/HelpStrings.zig
+++ b/src/build/HelpStrings.zig
@@ -40,6 +40,13 @@ pub fn addImport(self: *const HelpStrings, step: *std.Build.Step.Compile) void {
});
}
+pub fn addModuleImport(self: *const HelpStrings, module: *std.Build.Module) void {
+ // self.output.addStepDependencies(&step.step);
+ module.addAnonymousImport("help_strings", .{
+ .root_source_file = self.output,
+ });
+}
+
/// Install the help exe
pub fn install(self: *const HelpStrings) void {
self.exe.step.owner.installArtifact(self.exe);
diff --git a/src/build/ModuleDeps.zig b/src/build/ModuleDeps.zig
new file mode 100644
index 0000000000..0c056c2332
--- /dev/null
+++ b/src/build/ModuleDeps.zig
@@ -0,0 +1,517 @@
+const ModuleDeps = @This();
+
+const std = @import("std");
+const Scanner = @import("zig_wayland").Scanner;
+const Config = @import("Config.zig");
+const HelpStrings = @import("HelpStrings.zig");
+const MetallibStep = @import("MetallibStep.zig");
+const UnicodeTables = @import("UnicodeTables.zig");
+
+config: *const Config,
+
+options: *std.Build.Step.Options,
+help_strings: HelpStrings,
+metallib: ?*MetallibStep,
+unicode_tables: UnicodeTables,
+
+/// Used to keep track of a list of file sources.
+pub const LazyPathList = std.ArrayList(std.Build.LazyPath);
+
+pub fn init(b: *std.Build, cfg: *const Config) !ModuleDeps {
+ var result: ModuleDeps = .{
+ .config = cfg,
+ .help_strings = try HelpStrings.init(b, cfg),
+ .unicode_tables = try UnicodeTables.init(b),
+
+ // Setup by retarget
+ .options = undefined,
+ .metallib = undefined,
+ };
+ try result.initTarget(b, cfg.target);
+ return result;
+}
+
+/// Retarget our dependencies for another build target. Modifies in-place.
+pub fn retarget(
+ self: *const ModuleDeps,
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+) !ModuleDeps {
+ var result = self.*;
+ try result.initTarget(b, target);
+ return result;
+}
+
+/// Change the exe entrypoint.
+pub fn changeEntrypoint(
+ self: *const ModuleDeps,
+ b: *std.Build,
+ entrypoint: Config.ExeEntrypoint,
+) !ModuleDeps {
+ // Change our config
+ const config = try b.allocator.create(Config);
+ config.* = self.config.*;
+ config.exe_entrypoint = entrypoint;
+
+ var result = self.*;
+ result.config = config;
+ return result;
+}
+
+fn initTarget(
+ self: *ModuleDeps,
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+) !void {
+ // Update our metallib
+ self.metallib = MetallibStep.create(b, .{
+ .name = "Ghostty",
+ .target = target,
+ .sources = &.{b.path("src/renderer/shaders/cell.metal")},
+ });
+
+ // Change our config
+ const config = try b.allocator.create(Config);
+ config.* = self.config.*;
+ config.target = target;
+ self.config = config;
+
+ // Setup our shared build options
+ self.options = b.addOptions();
+ try self.config.addOptions(self.options);
+}
+
+pub fn add(
+ self: *const ModuleDeps,
+ module: *std.Build.Module,
+) !LazyPathList {
+ const b = module.owner;
+
+ // We could use our config.target/optimize fields here but its more
+ // correct to always match our step.
+ const target = module.resolved_target.?;
+ const optimize = module.optimize.?;
+ const resolved_target = target.result;
+
+ if (module.import_table.get("options") == null) {
+ module.addAnonymousImport("options", .{
+ .root_source_file = b.path("src/noop.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ }
+
+ // We maintain a list of our static libraries and return it so that
+ // we can build a single fat static library for the final app.
+ var static_libs = LazyPathList.init(b.allocator);
+ errdefer static_libs.deinit();
+
+ // Every exe gets build options populated
+ module.addOptions("build_options", self.options);
+
+ // Freetype
+ _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help
+ if (self.config.font_backend.hasFreetype()) {
+ const freetype_dep = b.dependency("freetype", .{
+ .target = target,
+ .optimize = optimize,
+ .@"enable-libpng" = true,
+ });
+ module.addImport("freetype", freetype_dep.module("freetype"));
+
+ if (b.systemIntegrationOption("freetype", .{})) {
+ module.linkSystemLibrary("bzip2", dynamic_link_opts);
+ module.linkSystemLibrary("freetype2", dynamic_link_opts);
+ } else {
+ module.linkLibrary(freetype_dep.artifact("freetype"));
+ try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin());
+ }
+ }
+
+ // Harfbuzz
+ _ = b.systemIntegrationOption("harfbuzz", .{}); // Shows it in help
+ if (self.config.font_backend.hasHarfbuzz()) {
+ const harfbuzz_dep = b.dependency("harfbuzz", .{
+ .target = target,
+ .optimize = optimize,
+ .@"enable-freetype" = true,
+ .@"enable-coretext" = self.config.font_backend.hasCoretext(),
+ });
+
+ module.addImport(
+ "harfbuzz",
+ harfbuzz_dep.module("harfbuzz"),
+ );
+ if (b.systemIntegrationOption("harfbuzz", .{})) {
+ module.linkSystemLibrary("harfbuzz", dynamic_link_opts);
+ } else {
+ module.linkLibrary(harfbuzz_dep.artifact("harfbuzz"));
+ try static_libs.append(harfbuzz_dep.artifact("harfbuzz").getEmittedBin());
+ }
+ }
+
+ // Fontconfig
+ _ = b.systemIntegrationOption("fontconfig", .{}); // Shows it in help
+ if (self.config.font_backend.hasFontconfig()) {
+ const fontconfig_dep = b.dependency("fontconfig", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport(
+ "fontconfig",
+ fontconfig_dep.module("fontconfig"),
+ );
+
+ if (b.systemIntegrationOption("fontconfig", .{})) {
+ module.linkSystemLibrary("fontconfig", dynamic_link_opts);
+ } else {
+ module.linkLibrary(fontconfig_dep.artifact("fontconfig"));
+ try static_libs.append(fontconfig_dep.artifact("fontconfig").getEmittedBin());
+ }
+ }
+
+ // Libpng - Ghostty doesn't actually use this directly, its only used
+ // through dependencies, so we only need to add it to our static
+ // libs list if we're not using system integration. The dependencies
+ // will handle linking it.
+ if (!b.systemIntegrationOption("libpng", .{})) {
+ const libpng_dep = b.dependency("libpng", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.linkLibrary(libpng_dep.artifact("png"));
+ try static_libs.append(libpng_dep.artifact("png").getEmittedBin());
+ }
+
+ // Zlib - same as libpng, only used through dependencies.
+ if (!b.systemIntegrationOption("zlib", .{})) {
+ const zlib_dep = b.dependency("zlib", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.linkLibrary(zlib_dep.artifact("z"));
+ try static_libs.append(zlib_dep.artifact("z").getEmittedBin());
+ }
+
+ // Oniguruma
+ const oniguruma_dep = b.dependency("oniguruma", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport("oniguruma", oniguruma_dep.module("oniguruma"));
+ if (b.systemIntegrationOption("oniguruma", .{})) {
+ module.linkSystemLibrary("oniguruma", dynamic_link_opts);
+ } else {
+ module.linkLibrary(oniguruma_dep.artifact("oniguruma"));
+ try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin());
+ }
+
+ // Glslang
+ const glslang_dep = b.dependency("glslang", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport("glslang", glslang_dep.module("glslang"));
+ if (b.systemIntegrationOption("glslang", .{})) {
+ module.linkSystemLibrary("glslang", dynamic_link_opts);
+ module.linkSystemLibrary("glslang-default-resource-limits", dynamic_link_opts);
+ } else {
+ module.linkLibrary(glslang_dep.artifact("glslang"));
+ try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin());
+ }
+
+ // Spirv-cross
+ const spirv_cross_dep = b.dependency("spirv_cross", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport("spirv_cross", spirv_cross_dep.module("spirv_cross"));
+ if (b.systemIntegrationOption("spirv-cross", .{})) {
+ module.linkSystemLibrary("spirv-cross", dynamic_link_opts);
+ } else {
+ module.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
+ try static_libs.append(spirv_cross_dep.artifact("spirv_cross").getEmittedBin());
+ }
+
+ // Simdutf
+ if (b.systemIntegrationOption("simdutf", .{})) {
+ module.linkSystemLibrary("simdutf", dynamic_link_opts);
+ } else {
+ const simdutf_dep = b.dependency("simdutf", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.linkLibrary(simdutf_dep.artifact("simdutf"));
+ try static_libs.append(simdutf_dep.artifact("simdutf").getEmittedBin());
+ }
+
+ // Sentry
+ if (self.config.sentry) {
+ const sentry_dep = b.dependency("sentry", .{
+ .target = target,
+ .optimize = optimize,
+ .backend = .breakpad,
+ });
+
+ module.addImport("sentry", sentry_dep.module("sentry"));
+
+ // Sentry
+ module.linkLibrary(sentry_dep.artifact("sentry"));
+ try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin());
+
+ // We also need to include breakpad in the static libs.
+ const breakpad_dep = sentry_dep.builder.dependency("breakpad", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ try static_libs.append(breakpad_dep.artifact("breakpad").getEmittedBin());
+ }
+
+ // Wasm we do manually since it is such a different build.
+ if (resolved_target.cpu.arch == .wasm32) {
+ const js_dep = b.dependency("zig_js", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport("zig-js", js_dep.module("zig-js"));
+
+ return static_libs;
+ }
+
+ // On Linux, we need to add a couple common library paths that aren't
+ // on the standard search list. i.e. GTK is often in /usr/lib/x86_64-linux-gnu
+ // on x86_64.
+ if (resolved_target.os.tag == .linux) {
+ const triple = try resolved_target.linuxTriple(b.allocator);
+ module.addLibraryPath(.{ .cwd_relative = b.fmt("/usr/lib/{s}", .{triple}) });
+ }
+
+ // C files
+ module.link_libc = true;
+ module.addIncludePath(b.path("src/stb"));
+ module.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} });
+ if (resolved_target.os.tag == .linux) {
+ module.addIncludePath(b.path("src/apprt/gtk"));
+ }
+
+ // C++ files
+ module.link_libcpp = true;
+ module.addIncludePath(b.path("src"));
+ {
+ // From hwy/detect_targets.h
+ const HWY_AVX3_SPR: c_int = 1 << 4;
+ const HWY_AVX3_ZEN4: c_int = 1 << 6;
+ const HWY_AVX3_DL: c_int = 1 << 7;
+ const HWY_AVX3: c_int = 1 << 8;
+
+ // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414
+ // To workaround this we just disable AVX512 support completely.
+ // The performance difference between AVX2 and AVX512 is not
+ // significant for our use case and AVX512 is very rare on consumer
+ // hardware anyways.
+ const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3;
+
+ module.addCSourceFiles(.{
+ .files = &.{
+ "src/simd/base64.cpp",
+ "src/simd/codepoint_width.cpp",
+ "src/simd/index_of.cpp",
+ "src/simd/vt.cpp",
+ },
+ .flags = if (resolved_target.cpu.arch == .x86_64) &.{
+ b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}),
+ } else &.{},
+ });
+ }
+
+ // We always require the system SDK so that our system headers are available.
+ // This makes things like `os/log.h` available for cross-compiling.
+ if (resolved_target.isDarwin()) {
+ try @import("apple_sdk").addPaths(b, module);
+
+ const metallib = self.metallib.?;
+ // metallib.output.addStepDependencies(&step.step);
+ module.addAnonymousImport("ghostty_metallib", .{
+ .root_source_file = metallib.output,
+ });
+ }
+
+ // Other dependencies, mostly pure Zig
+ module.addImport("opengl", b.dependency(
+ "opengl",
+ .{},
+ ).module("opengl"));
+ module.addImport("vaxis", b.dependency("vaxis", .{
+ .target = target,
+ .optimize = optimize,
+ }).module("vaxis"));
+ module.addImport("wuffs", b.dependency("wuffs", .{
+ .target = target,
+ .optimize = optimize,
+ }).module("wuffs"));
+ module.addImport("xev", b.dependency("libxev", .{
+ .target = target,
+ .optimize = optimize,
+ }).module("xev"));
+ module.addImport("z2d", b.addModule("z2d", .{
+ .root_source_file = b.dependency("z2d", .{}).path("src/z2d.zig"),
+ .target = target,
+ .optimize = optimize,
+ }));
+ module.addImport("ziglyph", b.dependency("ziglyph", .{
+ .target = target,
+ .optimize = optimize,
+ }).module("ziglyph"));
+ module.addImport("zf", b.dependency("zf", .{
+ .target = target,
+ .optimize = optimize,
+ .with_tui = false,
+ }).module("zf"));
+
+ // Mac Stuff
+ if (resolved_target.isDarwin()) {
+ const objc_dep = b.dependency("zig_objc", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ const macos_dep = b.dependency("macos", .{
+ .target = target,
+ .optimize = optimize,
+ });
+
+ module.addImport("objc", objc_dep.module("objc"));
+ module.addImport("macos", macos_dep.module("macos"));
+ module.linkLibrary(macos_dep.artifact("macos"));
+ try static_libs.append(macos_dep.artifact("macos").getEmittedBin());
+
+ if (self.config.renderer == .opengl) {
+ module.linkFramework("OpenGL", .{});
+ }
+ }
+
+ // cimgui
+ const cimgui_dep = b.dependency("cimgui", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.addImport("cimgui", cimgui_dep.module("cimgui"));
+ module.linkLibrary(cimgui_dep.artifact("cimgui"));
+ try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin());
+
+ // Highway
+ const highway_dep = b.dependency("highway", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.linkLibrary(highway_dep.artifact("highway"));
+ try static_libs.append(highway_dep.artifact("highway").getEmittedBin());
+
+ // utfcpp - This is used as a dependency on our hand-written C++ code
+ const utfcpp_dep = b.dependency("utfcpp", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ module.linkLibrary(utfcpp_dep.artifact("utfcpp"));
+ try static_libs.append(utfcpp_dep.artifact("utfcpp").getEmittedBin());
+
+ // If we're building an exe then we have additional dependencies.
+ // if (module.kind != .lib) {
+ // We always statically compile glad
+ module.addIncludePath(b.path("vendor/glad/include/"));
+ module.addCSourceFile(.{
+ .file = b.path("vendor/glad/src/gl.c"),
+ .flags = &.{},
+ });
+
+ // When we're targeting flatpak we ALWAYS link GTK so we
+ // get access to glib for dbus.
+ if (self.config.flatpak) module.linkSystemLibrary("gtk4", dynamic_link_opts);
+
+ switch (self.config.app_runtime) {
+ .none => {},
+
+ .glfw => glfw: {
+ const mach_glfw_dep = b.lazyDependency("mach_glfw", .{
+ .target = target,
+ .optimize = optimize,
+ }) orelse break :glfw;
+ module.addImport("glfw", mach_glfw_dep.module("mach-glfw"));
+ },
+
+ .gtk => {
+ module.linkSystemLibrary("gtk4", dynamic_link_opts);
+ if (self.config.adwaita) module.linkSystemLibrary("libadwaita-1", dynamic_link_opts);
+ if (self.config.x11) module.linkSystemLibrary("X11", dynamic_link_opts);
+
+ if (self.config.wayland) {
+ const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{
+ // We shouldn't be using getPath but we need to for now
+ // https://codeberg.org/ifreund/zig-wayland/issues/66
+ .wayland_xml = b.dependency("wayland", .{})
+ .path("protocol/wayland.xml"),
+ .wayland_protocols = b.dependency("wayland_protocols", .{})
+ .path(""),
+ });
+
+ const wayland = b.createModule(.{ .root_source_file = scanner.result });
+
+ const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml"));
+
+ scanner.generate("wl_compositor", 1);
+ scanner.generate("org_kde_kwin_blur_manager", 1);
+
+ module.addImport("wayland", wayland);
+ module.linkSystemLibrary("wayland-client", dynamic_link_opts);
+ }
+
+ {
+ const gresource = @import("../apprt/gtk/gresource.zig");
+
+ const wf = b.addWriteFiles();
+ const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml);
+
+ const generate_resources_c = b.addSystemCommand(&.{
+ "glib-compile-resources",
+ "--c-name",
+ "ghostty",
+ "--generate-source",
+ "--target",
+ });
+ const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c");
+ generate_resources_c.addFileArg(gresource_xml);
+ generate_resources_c.extra_file_dependencies = &gresource.dependencies;
+ module.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} });
+
+ const generate_resources_h = b.addSystemCommand(&.{
+ "glib-compile-resources",
+ "--c-name",
+ "ghostty",
+ "--generate-header",
+ "--target",
+ });
+ const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h");
+ generate_resources_h.addFileArg(gresource_xml);
+ generate_resources_h.extra_file_dependencies = &gresource.dependencies;
+ module.addIncludePath(ghostty_resources_h.dirname());
+ }
+ },
+ }
+ // }
+
+ self.help_strings.addModuleImport(module);
+ self.unicode_tables.addModuleImport(module);
+
+ return static_libs;
+}
+
+// For dynamic linking, we prefer dynamic linking and to search by
+// mode first. Mode first will search all paths for a dynamic library
+// before falling back to static.
+const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
+ .preferred_link_mode = .dynamic,
+ .search_strategy = .mode_first,
+};
diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig
index 16e7381fab..2be499bdfb 100644
--- a/src/build/SharedDeps.zig
+++ b/src/build/SharedDeps.zig
@@ -91,6 +91,13 @@ pub fn add(
// correct to always match our step.
const target = step.root_module.resolved_target.?;
const optimize = step.root_module.optimize.?;
+ if (step.root_module.import_table.get("options") == null) {
+ step.root_module.addAnonymousImport("options", .{
+ .root_source_file = b.path("src/noop.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ }
// We maintain a list of our static libraries and return it so that
// we can build a single fat static library for the final app.
@@ -430,9 +437,32 @@ pub fn add(
},
.gtk => {
+ const gobject = b.dependency("gobject", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ const gobject_imports = .{
+ .{ "gobject", "gobject2" },
+ .{ "glib", "glib2" },
+ .{ "gtk", "gtk4" },
+ .{ "gdk", "gdk4" },
+ };
+ inline for (gobject_imports) |import| {
+ const name, const module = import;
+ step.root_module.addImport(name, gobject.module(module));
+ }
+
step.linkSystemLibrary2("gtk4", dynamic_link_opts);
- if (self.config.adwaita) step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
- if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts);
+
+ if (self.config.adwaita) {
+ step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
+ step.root_module.addImport("adw", gobject.module("adw1"));
+ }
+
+ if (self.config.x11) {
+ step.linkSystemLibrary2("X11", dynamic_link_opts);
+ step.root_module.addImport("gdk_x11", gobject.module("gdkx114"));
+ }
if (self.config.wayland) {
const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{
@@ -450,12 +480,17 @@ pub fn add(
.target = target,
.optimize = optimize,
});
+
+ // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml"));
+ scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/server-decoration.xml"));
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
+ scanner.generate("org_kde_kwin_server_decoration_manager", 1);
step.root_module.addImport("wayland", wayland);
+ step.root_module.addImport("gdk_wayland", gobject.module("gdkwayland4"));
step.linkSystemLibrary2("wayland-client", dynamic_link_opts);
}
diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig
index 0159de442c..aa008aec8e 100644
--- a/src/build/UnicodeTables.zig
+++ b/src/build/UnicodeTables.zig
@@ -15,7 +15,6 @@ pub fn init(b: *std.Build) !UnicodeTables {
.root_source_file = b.path("src/unicode/props.zig"),
.target = b.host,
});
- exe.linkLibC();
const ziglyph_dep = b.dependency("ziglyph", .{
.target = b.host,
@@ -37,6 +36,14 @@ pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void
});
}
+/// Add the "unicode_tables" import.
+pub fn addModuleImport(self: *const UnicodeTables, module: *std.Build.Module) void {
+ // self.output.addStepDependencies(&step.step);
+ module.addAnonymousImport("unicode_tables", .{
+ .root_source_file = self.output,
+ });
+}
+
/// Install the exe
pub fn install(self: *const UnicodeTables, b: *std.Build) void {
b.installArtifact(self.exe);
diff --git a/src/build/main.zig b/src/build/main.zig
index 2917919174..4691bd7025 100644
--- a/src/build/main.zig
+++ b/src/build/main.zig
@@ -16,6 +16,7 @@ pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig");
pub const GhosttyWebdata = @import("GhosttyWebdata.zig");
pub const HelpStrings = @import("HelpStrings.zig");
pub const SharedDeps = @import("SharedDeps.zig");
+pub const ModuleDeps = @import("ModuleDeps.zig");
pub const UnicodeTables = @import("UnicodeTables.zig");
// Steps
diff --git a/src/build/webgen/main_actions.zig b/src/build/webgen/main_actions.zig
index f4dffbc139..5002a5bac9 100644
--- a/src/build/webgen/main_actions.zig
+++ b/src/build/webgen/main_actions.zig
@@ -1,58 +1,8 @@
const std = @import("std");
const help_strings = @import("help_strings");
-const KeybindAction = @import("../../input/Binding.zig").Action;
+const helpgen_actions = @import("../../input/helpgen_actions.zig");
pub fn main() !void {
const output = std.io.getStdOut().writer();
- try genKeybindActions(output);
-}
-
-pub fn genKeybindActions(writer: anytype) !void {
- // Write the header
- try writer.writeAll(
- \\---
- \\title: Keybinding Action Reference
- \\description: Reference of all Ghostty keybinding actions.
- \\editOnGithubLink: https://github.com/ghostty-org/ghostty/edit/main/src/input/Binding.zig
- \\---
- \\
- \\This is a reference of all Ghostty keybinding actions.
- \\
- \\
- );
-
- @setEvalBranchQuota(5_000);
-
- var buffer = std.ArrayList(u8).init(std.heap.page_allocator);
- defer buffer.deinit();
-
- const fields = @typeInfo(KeybindAction).Union.fields;
- inline for (fields) |field| {
- if (field.name[0] == '_') continue;
-
- // Write previously stored doc comment below all related actions
- if (@hasDecl(help_strings.KeybindAction, field.name)) {
- try writer.writeAll(buffer.items);
- try writer.writeAll("\n");
-
- buffer.clearRetainingCapacity();
- }
-
- // Write the field name.
- try writer.writeAll("## `");
- try writer.writeAll(field.name);
- try writer.writeAll("`\n");
-
- if (@hasDecl(help_strings.KeybindAction, field.name)) {
- var iter = std.mem.splitScalar(
- u8,
- @field(help_strings.KeybindAction, field.name),
- '\n',
- );
- while (iter.next()) |s| {
- try buffer.appendSlice(s);
- try buffer.appendSlice("\n");
- }
- }
- }
+ try helpgen_actions.generate(output, .markdown, true, std.heap.page_allocator);
}
diff --git a/src/build/webgen/main_commands.zig b/src/build/webgen/main_commands.zig
new file mode 100644
index 0000000000..6e6b00c5e3
--- /dev/null
+++ b/src/build/webgen/main_commands.zig
@@ -0,0 +1,51 @@
+const std = @import("std");
+const Action = @import("../../cli/action.zig").Action;
+const help_strings = @import("help_strings");
+
+pub fn main() !void {
+ const output = std.io.getStdOut().writer();
+ try genActions(output);
+}
+
+// Note: as a shortcut for defining inline editOnGithubLinks per cli action the user
+// is directed to the folder view on Github. This includes a README pointing them to
+// the files to edit.
+pub fn genActions(writer: anytype) !void {
+ // Write the header
+ try writer.writeAll(
+ \\---
+ \\title: Reference
+ \\description: Reference of all Ghostty action subcommands.
+ \\editOnGithubLink: https://github.com/ghostty-org/ghostty/tree/main/src/cli
+ \\---
+ \\Ghostty includes a number of utility actions that can be accessed as subcommands.
+ \\Actions provide utilities to work with config, list keybinds, list fonts, demo themes,
+ \\and debug.
+ \\
+ );
+
+ inline for (@typeInfo(Action).Enum.fields) |field| {
+ const action = std.meta.stringToEnum(Action, field.name).?;
+
+ switch (action) {
+ .help, .version => try writer.writeAll("## " ++ field.name ++ "\n"),
+ else => try writer.writeAll("## " ++ field.name ++ "\n"),
+ }
+
+ if (@hasDecl(help_strings.Action, field.name)) {
+ var iter = std.mem.splitScalar(u8, @field(help_strings.Action, field.name), '\n');
+ var first = true;
+ while (iter.next()) |s| {
+ try writer.writeAll(s);
+ try writer.writeAll("\n");
+ first = false;
+ }
+ try writer.writeAll("\n```\n");
+ switch (action) {
+ .help, .version => try writer.writeAll("ghostty --" ++ field.name ++ "\n"),
+ else => try writer.writeAll("ghostty +" ++ field.name ++ "\n"),
+ }
+ try writer.writeAll("```\n\n");
+ }
+ }
+}
diff --git a/src/cli/README.md b/src/cli/README.md
new file mode 100644
index 0000000000..7a1d99409c
--- /dev/null
+++ b/src/cli/README.md
@@ -0,0 +1,13 @@
+# Subcommand Actions
+
+This is the cli specific code. It contains cli actions and tui definitions and
+argument parsing.
+
+This README is meant as developer documentation and not as user documentation.
+For user documentation, see the main README or [ghostty.org](https://ghostty.org/docs).
+
+## Updating documentation
+
+Each cli action is defined in it's own file. Documentation for each action is defined
+in the doc comment associated with the `run` function. For example the `run` function
+in `list_keybinds.zig` contains the help text for `ghostty +list-keybinds`.
diff --git a/src/cli/action.zig b/src/cli/action.zig
index a84a400241..693d509fca 100644
--- a/src/cli/action.zig
+++ b/src/cli/action.zig
@@ -45,12 +45,12 @@ pub const Action = enum {
// Validate passed config file
@"validate-config",
- // List, (eventually) view, and (eventually) send crash reports.
- @"crash-report",
-
// Show which font face Ghostty loads a codepoint from.
@"show-face",
+ // List, (eventually) view, and (eventually) send crash reports.
+ @"crash-report",
+
pub const Error = error{
/// Multiple actions were detected. You can specify at most one
/// action on the CLI otherwise the behavior desired is ambiguous.
diff --git a/src/cli/args.zig b/src/cli/args.zig
index 23dcf77331..7385e6a3ea 100644
--- a/src/cli/args.zig
+++ b/src/cli/args.zig
@@ -8,6 +8,8 @@ const internal_os = @import("../os/main.zig");
const Diagnostic = diags.Diagnostic;
const DiagnosticList = diags.DiagnosticList;
+const log = std.log.scoped(.cli);
+
// TODO:
// - Only `--long=value` format is accepted. Do we want to allow
// `--long value`? Not currently allowed.
@@ -38,6 +40,12 @@ pub const Error = error{
/// "DiagnosticList" and any diagnostic messages will be added to that list.
/// When diagnostics are present, only allocation errors will be returned.
///
+/// If the destination type has a decl "renamed", it must be of type
+/// std.StaticStringMap([]const u8) and contains a mapping from the old
+/// field name to the new field name. This is used to allow renaming fields
+/// while still supporting the old name. If a renamed field is set, parsing
+/// will automatically set the new field name.
+///
/// Note: If the arena is already non-null, then it will be used. In this
/// case, in the case of an error some memory might be leaked into the arena.
pub fn parse(
@@ -49,6 +57,24 @@ pub fn parse(
const info = @typeInfo(T);
assert(info == .Struct);
+ comptime {
+ // Verify all renamed fields are valid (source does not exist,
+ // destination does exist).
+ if (@hasDecl(T, "renamed")) {
+ for (T.renamed.keys(), T.renamed.values()) |key, value| {
+ if (@hasField(T, key)) {
+ @compileLog(key);
+ @compileError("renamed field source exists");
+ }
+
+ if (!@hasField(T, value)) {
+ @compileLog(value);
+ @compileError("renamed field destination does not exist");
+ }
+ }
+ }
+ }
+
// Make an arena for all our allocations if we support it. Otherwise,
// use an allocator that always fails. If the arena is already set on
// the config, then we reuse that. See memory note in parse docs.
@@ -367,6 +393,16 @@ pub fn parseIntoField(
}
}
+ // Unknown field, is the field renamed?
+ if (@hasDecl(T, "renamed")) {
+ for (T.renamed.keys(), T.renamed.values()) |old, new| {
+ if (mem.eql(u8, old, key)) {
+ try parseIntoField(T, alloc, dst, new, value);
+ return;
+ }
+ }
+ }
+
return error.InvalidField;
}
@@ -1104,6 +1140,24 @@ test "parseIntoField: tagged union missing tag" {
);
}
+test "parseIntoField: renamed field" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: []const u8,
+
+ const renamed = std.StaticStringMap([]const u8).initComptime(&.{
+ .{ "old", "a" },
+ });
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "old", "42");
+ try testing.expectEqualStrings("42", data.a);
+}
+
/// An iterator that considers its location to be CLI args. It
/// iterates through an underlying iterator and increments a counter
/// to track the current CLI arg index.
@@ -1206,9 +1260,11 @@ pub fn LineIterator(comptime ReaderType: type) type {
const buf = buf: {
while (true) {
// Read the full line
- var entry = self.r.readUntilDelimiterOrEof(self.entry[2..], '\n') catch {
- // TODO: handle errors
- unreachable;
+ var entry = self.r.readUntilDelimiterOrEof(self.entry[2..], '\n') catch |err| switch (err) {
+ inline else => |e| {
+ log.warn("cannot read from \"{s}\": {}", .{ self.filepath, e });
+ return null;
+ },
} orelse return null;
// Increment our line counter
diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig
index dd5fe99cce..ff85097972 100644
--- a/src/cli/crash_report.zig
+++ b/src/cli/crash_report.zig
@@ -53,7 +53,7 @@ pub fn run(alloc_gpa: Allocator) !u8 {
// print a message, otherwise we do nothing.
if (reports.items.len == 0) {
if (std.posix.isatty(stdout.handle)) {
- try stdout.writeAll("No crash reports! 👻");
+ try stdout.writeAll("No crash reports! 👻\n");
}
return 0;
}
diff --git a/src/cli/help.zig b/src/cli/help.zig
index daadc37ccd..22fe27d8d1 100644
--- a/src/cli/help.zig
+++ b/src/cli/help.zig
@@ -15,9 +15,11 @@ pub const Options = struct {
}
};
-/// The `help` command shows general help about Ghostty. You can also specify
-/// `--help` or `-h` along with any action such as `+list-themes` to see help
-/// for a specific action.
+/// The `help` command shows general help about Ghostty. Recognized as either
+/// `-h, `--help`, or like other actions `+help`.
+///
+/// You can also specify `--help` or `-h` along with any action such as
+/// `+list-themes` to see help for a specific action.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig
index 65b9dcdadc..1d17873cc9 100644
--- a/src/cli/list_actions.zig
+++ b/src/cli/list_actions.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Allocator = std.mem.Allocator;
-const help_strings = @import("help_strings");
+const helpgen_actions = @import("../input/helpgen_actions.zig");
pub const Options = struct {
/// If `true`, print out documentation about the action associated with the
@@ -24,7 +24,9 @@ pub const Options = struct {
/// actions for Ghostty. These are distinct from the CLI Actions which can
/// be listed via `+help`
///
-/// The `--docs` argument will print out the documentation for each action.
+/// Flags:
+///
+/// * `--docs`: will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
@@ -36,19 +38,7 @@ pub fn run(alloc: Allocator) !u8 {
}
const stdout = std.io.getStdOut().writer();
- const info = @typeInfo(help_strings.KeybindAction);
- inline for (info.Struct.decls) |field| {
- try stdout.print("{s}", .{field.name});
- if (opts.docs) {
- try stdout.print(":\n", .{});
- var iter = std.mem.splitScalar(u8, std.mem.trimRight(u8, @field(help_strings.KeybindAction, field.name), &std.ascii.whitespace), '\n');
- while (iter.next()) |line| {
- try stdout.print(" {s}\n", .{line});
- }
- } else {
- try stdout.print("\n", .{});
- }
- }
+ try helpgen_actions.generate(stdout, .plaintext, opts.docs, std.heap.page_allocator);
return 0;
}
diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig
index 9d1f34cd1e..e8a010ecd0 100644
--- a/src/cli/list_fonts.zig
+++ b/src/cli/list_fonts.zig
@@ -44,14 +44,21 @@ pub const Options = struct {
/// the sorting will be disabled and the results instead will be shown in the
/// same priority order Ghostty would use to pick a font.
///
-/// The `--family` argument can be used to filter results to a specific family.
-/// The family handling is identical to the `font-family` set of Ghostty
-/// configuration values, so this can be used to debug why your desired font may
-/// not be loading.
+/// Flags:
///
-/// The `--bold` and `--italic` arguments can be used to filter results to
-/// specific styles. It is not guaranteed that only those styles are returned,
-/// it will just prioritize fonts that match those styles.
+/// * `--bold`: Filter results to specific bold styles. It is not guaranteed
+/// that only those styles are returned. They are only prioritized.
+///
+/// * `--italic`: Filter results to specific italic styles. It is not guaranteed
+/// that only those styles are returned. They are only prioritized.
+///
+/// * `--style`: Filter results based on the style string advertised by a font.
+/// It is not guaranteed that only those styles are returned. They are only
+/// prioritized.
+///
+/// * `--family`: Filter results to a specific font family. The family handling
+/// is identical to the `font-family` set of Ghostty configuration values, so
+/// this can be used to debug why your desired font may not be loading.
pub fn run(alloc: Allocator) !u8 {
var iter = try args.argsIterator(alloc);
defer iter.deinit();
diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig
index ddaf751770..6cd989201c 100644
--- a/src/cli/list_keybinds.zig
+++ b/src/cli/list_keybinds.zig
@@ -42,11 +42,15 @@ pub const Options = struct {
/// changes to the keybinds it will print out the default ones configured for
/// Ghostty
///
-/// The `--default` argument will print out all the default keybinds configured
-/// for Ghostty
+/// Flags:
///
-/// The `--plain` flag will disable formatting and make the output more
-/// friendly for Unix tooling. This is default when not printing to a tty.
+/// * `--default`: will print out all the default keybinds
+///
+/// * `--docs`: currently does nothing, intended to print out documentation
+/// about the action associated with the keybinds
+///
+/// * `--plain`: will disable formatting and make the output more
+/// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
@@ -64,7 +68,9 @@ pub fn run(alloc: Allocator) !u8 {
// Despite being under the posix namespace, this also works on Windows as of zig 0.13.0
if (tui.can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) {
- return prettyPrint(alloc, config.keybind);
+ var arena = std.heap.ArenaAllocator.init(alloc);
+ defer arena.deinit();
+ return prettyPrint(arena.allocator(), config.keybind);
} else {
try config.keybind.formatEntryDocs(
configpkg.entryFormatter("keybind", stdout.writer()),
@@ -75,6 +81,111 @@ pub fn run(alloc: Allocator) !u8 {
return 0;
}
+const TriggerList = std.SinglyLinkedList(Binding.Trigger);
+
+const ChordBinding = struct {
+ triggers: TriggerList,
+ action: Binding.Action,
+
+ // Order keybinds based on various properties
+ // 1. Longest chord sequence
+ // 2. Most active modifiers
+ // 3. Alphabetically by active modifiers
+ // 4. Trigger key order
+ // These properties propagate through chorded keypresses
+ //
+ // Adapted from Binding.lessThan
+ pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool {
+ const lhs_len = lhs.triggers.len();
+ const rhs_len = rhs.triggers.len();
+
+ std.debug.assert(lhs_len != 0);
+ std.debug.assert(rhs_len != 0);
+
+ if (lhs_len != rhs_len) {
+ return lhs_len > rhs_len;
+ }
+
+ const lhs_count: usize = blk: {
+ var count: usize = 0;
+ var maybe_trigger = lhs.triggers.first;
+ while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
+ if (trigger.data.mods.super) count += 1;
+ if (trigger.data.mods.ctrl) count += 1;
+ if (trigger.data.mods.shift) count += 1;
+ if (trigger.data.mods.alt) count += 1;
+ }
+ break :blk count;
+ };
+ const rhs_count: usize = blk: {
+ var count: usize = 0;
+ var maybe_trigger = rhs.triggers.first;
+ while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
+ if (trigger.data.mods.super) count += 1;
+ if (trigger.data.mods.ctrl) count += 1;
+ if (trigger.data.mods.shift) count += 1;
+ if (trigger.data.mods.alt) count += 1;
+ }
+
+ break :blk count;
+ };
+
+ if (lhs_count != rhs_count)
+ return lhs_count > rhs_count;
+
+ {
+ var l_trigger = lhs.triggers.first;
+ var r_trigger = rhs.triggers.first;
+ while (l_trigger != null and r_trigger != null) {
+ const l_int = l_trigger.?.data.mods.int();
+ const r_int = r_trigger.?.data.mods.int();
+
+ if (l_int != r_int) {
+ return l_int > r_int;
+ }
+
+ l_trigger = l_trigger.?.next;
+ r_trigger = r_trigger.?.next;
+ }
+ }
+
+ var l_trigger = lhs.triggers.first;
+ var r_trigger = rhs.triggers.first;
+
+ while (l_trigger != null and r_trigger != null) {
+ const lhs_key: c_int = blk: {
+ switch (l_trigger.?.data.key) {
+ .translated => |key| break :blk @intFromEnum(key),
+ .physical => |key| break :blk @intFromEnum(key),
+ .unicode => |key| break :blk @intCast(key),
+ }
+ };
+ const rhs_key: c_int = blk: {
+ switch (r_trigger.?.data.key) {
+ .translated => |key| break :blk @intFromEnum(key),
+ .physical => |key| break :blk @intFromEnum(key),
+ .unicode => |key| break :blk @intCast(key),
+ }
+ };
+
+ l_trigger = l_trigger.?.next;
+ r_trigger = r_trigger.?.next;
+
+ if (l_trigger == null or r_trigger == null) {
+ return lhs_key < rhs_key;
+ }
+
+ if (lhs_key != rhs_key) {
+ return lhs_key < rhs_key;
+ }
+ }
+
+ // The previous loop will always return something on its final iteration so we cannot
+ // reach this point
+ unreachable;
+ }
+};
+
fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
// Set up vaxis
var tty = try vaxis.Tty.init();
@@ -107,26 +218,11 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const win = vx.window();
- // Get all of our keybinds into a list. We also search for the longest printed keyname so we can
- // align things nicely
+ // Generate a list of bindings, recursively traversing chorded keybindings
var iter = keybinds.set.bindings.iterator();
- var bindings = std.ArrayList(Binding).init(alloc);
- var widest_key: u16 = 0;
- var buf: [64]u8 = undefined;
- while (iter.next()) |bind| {
- const action = switch (bind.value_ptr.*) {
- .leader => continue, // TODO: support this
- .leaf => |leaf| leaf.action,
- };
- const key = switch (bind.key_ptr.key) {
- .translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),
- .physical => |k| try std.fmt.bufPrint(&buf, "physical:{s}", .{@tagName(k)}),
- .unicode => |c| try std.fmt.bufPrint(&buf, "{u}", .{c}),
- };
- widest_key = @max(widest_key, win.gwidth(key));
- try bindings.append(.{ .trigger = bind.key_ptr.*, .action = action });
- }
- std.mem.sort(Binding, bindings.items, {}, Binding.lessThan);
+ const bindings, const widest_chord = try iterateBindings(alloc, &iter, &win);
+
+ std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan);
// Set up styles for each modifier
const super_style: vaxis.Style = .{ .fg = .{ .index = 1 } };
@@ -134,41 +230,41 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } };
const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } };
- var longest_col: u16 = 0;
-
// Print the list
- for (bindings.items) |bind| {
+ for (bindings) |bind| {
win.clear();
var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
- const trigger = bind.trigger;
- if (trigger.mods.super) {
- result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
- result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
- }
- if (trigger.mods.ctrl) {
- result = win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
- result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
- }
- if (trigger.mods.alt) {
- result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
- result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
- }
- if (trigger.mods.shift) {
- result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
- result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
- }
-
- const key = switch (trigger.key) {
- .translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}),
- .physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}),
- .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
- };
- // We don't track the key print because we index the action off the *widest* key so we get
- // nice alignment no matter what was printed for mods
- _ = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
+ var maybe_trigger = bind.triggers.first;
+ while (maybe_trigger) |trigger| : (maybe_trigger = trigger.next) {
+ if (trigger.data.mods.super) {
+ result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.ctrl) {
+ result = win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.alt) {
+ result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.shift) {
+ result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ const key = switch (trigger.data.key) {
+ .translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}),
+ .physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}),
+ .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
+ };
+ result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
- if (longest_col < result.col) longest_col = result.col;
+ // Print a separator between chorded keys
+ if (trigger.next != null) {
+ result = win.printSegment(.{ .text = " > ", .style = .{ .bold = true, .fg = .{ .index = 6 } } }, .{ .col_offset = result.col });
+ }
+ }
const action = try std.fmt.allocPrint(alloc, "{}", .{bind.action});
// If our action has an argument, we print the argument in a different color
@@ -177,12 +273,69 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
.{ .text = action[0..idx] },
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
- }, .{ .col_offset = longest_col + widest_key + 2 });
+ }, .{ .col_offset = widest_chord + 3 });
} else {
- _ = win.printSegment(.{ .text = action }, .{ .col_offset = longest_col + widest_key + 2 });
+ _ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 });
}
try vx.prettyPrint(writer);
}
try buf_writer.flush();
return 0;
}
+
+fn iterateBindings(alloc: Allocator, iter: anytype, win: *const vaxis.Window) !struct { []ChordBinding, u16 } {
+ var widest_chord: u16 = 0;
+ var bindings = std.ArrayList(ChordBinding).init(alloc);
+ while (iter.next()) |bind| {
+ const width = blk: {
+ var buf = std.ArrayList(u8).init(alloc);
+ const t = bind.key_ptr.*;
+
+ if (t.mods.super) try std.fmt.format(buf.writer(), "super + ", .{});
+ if (t.mods.ctrl) try std.fmt.format(buf.writer(), "ctrl + ", .{});
+ if (t.mods.alt) try std.fmt.format(buf.writer(), "alt + ", .{});
+ if (t.mods.shift) try std.fmt.format(buf.writer(), "shift + ", .{});
+
+ switch (t.key) {
+ .translated => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}),
+ .physical => |k| try std.fmt.format(buf.writer(), "physical:{s}", .{@tagName(k)}),
+ .unicode => |c| try std.fmt.format(buf.writer(), "{u}", .{c}),
+ }
+
+ break :blk win.gwidth(buf.items);
+ };
+
+ switch (bind.value_ptr.*) {
+ .leader => |leader| {
+
+ // Recursively iterate on the set of bindings for this leader key
+ var n_iter = leader.bindings.iterator();
+ const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win);
+
+ // Prepend the current keybind onto the list of sub-binds
+ for (sub_bindings) |*nb| {
+ const prepend_node = try alloc.create(TriggerList.Node);
+ prepend_node.* = TriggerList.Node{ .data = bind.key_ptr.* };
+ nb.triggers.prepend(prepend_node);
+ }
+
+ // Add the longest sub-bind width to the current bind width along with a padding
+ // of 5 for the ' > ' spacer
+ widest_chord = @max(widest_chord, width + max_width + 5);
+ try bindings.appendSlice(sub_bindings);
+ },
+ .leaf => |leaf| {
+ const node = try alloc.create(TriggerList.Node);
+ node.* = TriggerList.Node{ .data = bind.key_ptr.* };
+ const triggers = TriggerList{
+ .first = node,
+ };
+
+ widest_chord = @max(widest_chord, width);
+ try bindings.append(.{ .triggers = triggers, .action = leaf.action });
+ },
+ }
+ }
+
+ return .{ try bindings.toOwnedSlice(), widest_chord };
+}
diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig
index 22e22a972d..f7ee10ce65 100644
--- a/src/cli/list_themes.zig
+++ b/src/cli/list_themes.zig
@@ -91,6 +91,7 @@ const ThemeListElement = struct {
/// Flags:
///
/// * `--path`: Show the full path to the theme.
+///
/// * `--plain`: Force a plain listing of themes.
pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
var opts: Options = .{};
diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig
index 1615ef66b3..5bc6ff4062 100644
--- a/src/cli/validate_config.zig
+++ b/src/cli/validate_config.zig
@@ -23,10 +23,13 @@ pub const Options = struct {
/// The `validate-config` command is used to validate a Ghostty config file.
///
-/// When executed without any arguments, this will load the config from the default location.
+/// When executed without any arguments, this will load the config from the default
+/// location.
///
-/// The `--config-file` argument can be passed to validate a specific target config
-/// file in a non-default location.
+/// Flags:
+///
+/// * `--config-file`: can be passed to validate a specific target config file in
+/// a non-default location
pub fn run(alloc: std.mem.Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
diff --git a/src/cli/version.zig b/src/cli/version.zig
index b001525896..4a6af242c3 100644
--- a/src/cli/version.zig
+++ b/src/cli/version.zig
@@ -10,7 +10,8 @@ const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").
pub const Options = struct {};
-/// The `version` command is used to display information about Ghostty.
+/// The `version` command is used to display information about Ghostty. Recognized as
+/// either `+version` or `--version`.
pub fn run(alloc: Allocator) !u8 {
_ = alloc;
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 144796554d..802c77e2e7 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -42,6 +42,15 @@ const c = @cImport({
@cInclude("unistd.h");
});
+/// Renamed fields, used by cli.parse
+pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{
+ // Ghostty 1.1 introduced background-blur support for Linux which
+ // doesn't support a specific radius value. The renaming is to let
+ // one field be used for both platforms (macOS retained the ability
+ // to set a radius).
+ .{ "background-blur-radius", "background-blur" },
+});
+
/// The font families to use.
///
/// You can generate the list of valid values using the CLI:
@@ -248,6 +257,28 @@ const c = @cImport({
/// This is currently only supported on macOS.
@"font-thicken-strength": u8 = 255,
+/// What color space to use when performing alpha blending.
+///
+/// This affects the appearance of text and of any images with transparency.
+/// Additionally, custom shaders will receive colors in the configured space.
+///
+/// Valid values:
+///
+/// * `native` - Perform alpha blending in the native color space for the OS.
+/// On macOS this corresponds to Display P3, and on Linux it's sRGB.
+///
+/// * `linear` - Perform alpha blending in linear space. This will eliminate
+/// the darkening artifacts around the edges of text that are very visible
+/// when certain color combinations are used (e.g. red / green), but makes
+/// dark text look much thinner than normal and light text much thicker.
+/// This is also sometimes known as "gamma correction".
+/// (Currently only supported on macOS. Has no effect on Linux.)
+///
+/// * `linear-corrected` - Same as `linear`, but with a correction step applied
+/// for text that makes it look nearly or completely identical to `native`,
+/// but without any of the darkening artifacts.
+@"alpha-blending": AlphaBlending = .native,
+
/// All of the configurations behavior adjust various metrics determined by the
/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%,
/// etc.). In each case, the values represent the amount to change the original
@@ -256,7 +287,7 @@ const c = @cImport({
/// For example, a value of `1` increases the value by 1; it does not set it to
/// literally 1. A value of `20%` increases the value by 20%. And so on.
///
-/// There is little to no validation on these values so the wrong values (i.e.
+/// There is little to no validation on these values so the wrong values (e.g.
/// `-100%`) can cause the terminal to be unusable. Use with caution and reason.
///
/// Some values are clamped to minimum or maximum values. This can make it
@@ -286,14 +317,14 @@ const c = @cImport({
/// See the notes about adjustments in `adjust-cell-width`.
@"adjust-underline-thickness": ?MetricModifier = null,
/// Distance in pixels or percentage adjustment from the top of the cell to the top of the strikethrough.
-/// Increase to move strikethrough DOWN, decrease to move underline UP.
+/// Increase to move strikethrough DOWN, decrease to move strikethrough UP.
/// See the notes about adjustments in `adjust-cell-width`.
@"adjust-strikethrough-position": ?MetricModifier = null,
/// Thickness in pixels or percentage adjustment of the strikethrough.
/// See the notes about adjustments in `adjust-cell-width`.
@"adjust-strikethrough-thickness": ?MetricModifier = null,
/// Distance in pixels or percentage adjustment from the top of the cell to the top of the overline.
-/// Increase to move overline DOWN, decrease to move underline UP.
+/// Increase to move overline DOWN, decrease to move overline UP.
/// See the notes about adjustments in `adjust-cell-width`.
@"adjust-overline-position": ?MetricModifier = null,
/// Thickness in pixels or percentage adjustment of the overline.
@@ -441,7 +472,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The minimum contrast ratio between the foreground and background colors.
/// The contrast ratio is a value between 1 and 21. A value of 1 allows for no
-/// contrast (i.e. black on black). This value is the contrast ratio as defined
+/// contrast (e.g. black on black). This value is the contrast ratio as defined
/// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/).
///
/// If you want to avoid invisible text (same color as background), a value of
@@ -623,7 +654,7 @@ palette: Palette = .{},
/// need to set environment-specific settings and/or install third-party plugins
/// in order to support background blur, as there isn't a unified interface for
/// doing so.
-@"background-blur-radius": BackgroundBlur = .false,
+@"background-blur": BackgroundBlur = .false,
/// The opacity level (opposite of transparency) of an unfocused split.
/// Unfocused splits by default are slightly faded out to make it easier to see
@@ -696,7 +727,7 @@ command: ?[]const u8 = null,
/// injecting any configured shell integration into the command's
/// environment. With `-e` its highly unlikely that you're executing a
/// shell and forced shell integration is likely to cause problems
-/// (i.e. by wrapping your command in a shell, setting env vars, etc.).
+/// (e.g. by wrapping your command in a shell, setting env vars, etc.).
/// This is a safety measure to prevent unexpected behavior. If you want
/// shell integration with a `-e`-executed command, you must either
/// name your binary appropriately or source the shell integration script
@@ -744,7 +775,7 @@ command: ?[]const u8 = null,
/// Match a regular expression against the terminal text and associate clicking
/// it with an action. This can be used to match URLs, file paths, etc. Actions
-/// can be opening using the system opener (i.e. `open` or `xdg-open`) or
+/// can be opening using the system opener (e.g. `open` or `xdg-open`) or
/// executing any arbitrary binding action.
///
/// Links that are configured earlier take precedence over links that are
@@ -764,6 +795,11 @@ link: RepeatableLink = .{},
/// `link`). If you want to customize URL matching, use `link` and disable this.
@"link-url": bool = true,
+/// Whether to start the window in a maximized state. This setting applies
+/// to new windows and does not apply to tabs, splits, etc. However, this setting
+/// will apply to all new windows, not just the first one.
+maximize: bool = false,
+
/// Start new windows in fullscreen. This setting applies to new windows and
/// does not apply to tabs, splits, etc. However, this setting will apply to all
/// new windows, not just the first one.
@@ -845,7 +881,7 @@ class: ?[:0]const u8 = null,
/// Valid keys are currently only listed in the
/// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255).
/// This is a documentation limitation and we will improve this in the future.
-/// A common gotcha is that numeric keys are written as words: i.e. `one`,
+/// A common gotcha is that numeric keys are written as words: e.g. `one`,
/// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in
/// the future.
///
@@ -888,7 +924,7 @@ class: ?[:0]const u8 = null,
/// * Ghostty will wait an indefinite amount of time for the next key in
/// the sequence. There is no way to specify a timeout. The only way to
/// force the output of a prefix key is to assign another keybind to
-/// specifically output that key (i.e. `ctrl+a>ctrl+a=text:foo`) or
+/// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or
/// press an unbound key which will send both keys to the program.
///
/// * If a prefix in a sequence is previously bound, the sequence will
@@ -918,13 +954,13 @@ class: ?[:0]const u8 = null,
/// including `physical:`-prefixed triggers without specifying the
/// prefix.
///
-/// * `csi:text` - Send a CSI sequence. i.e. `csi:A` sends "cursor up".
+/// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up".
///
-/// * `esc:text` - Send an escape sequence. i.e. `esc:d` deletes to the
+/// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the
/// end of the word to the right.
///
/// * `text:text` - Send a string. Uses Zig string literal syntax.
-/// i.e. `text:\x15` sends Ctrl-U.
+/// e.g. `text:\x15` sends Ctrl-U.
///
/// * All other actions can be found in the documentation or by using the
/// `ghostty +list-actions` command.
@@ -950,12 +986,12 @@ class: ?[:0]const u8 = null,
/// keybinds only apply to the focused terminal surface. If this is true,
/// then the keybind will be sent to all terminal surfaces. This only
/// applies to actions that are surface-specific. For actions that
-/// are already global (i.e. `quit`), this prefix has no effect.
+/// are already global (e.g. `quit`), this prefix has no effect.
///
/// * `global:` - Make the keybind global. By default, keybinds only work
/// within Ghostty and under the right conditions (application focused,
/// sometimes terminal focused, etc.). If you want a keybind to work
-/// globally across your system (i.e. even when Ghostty is not focused),
+/// globally across your system (e.g. even when Ghostty is not focused),
/// specify this prefix. This prefix implies `all:`. Note: this does not
/// work in all environments; see the additional notes below for more
/// information.
@@ -979,6 +1015,12 @@ class: ?[:0]const u8 = null,
/// performable (acting identically to not having a keybind set at
/// all).
///
+/// Performable keybinds will not appear as menu shortcuts in the
+/// application menu. This is because the menu shortcuts force the
+/// action to be performed regardless of the state of the terminal.
+/// Performable keybinds will still work, they just won't appear as
+/// a shortcut label in the menu.
+///
/// Keybind triggers are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
/// set later will overwrite the keybind set earlier. In this case, the
@@ -1056,7 +1098,7 @@ keybind: Keybinds = .{},
/// any of the heuristics that disable extending noted below.
///
/// The "extend" value will be disabled in certain scenarios. On primary
-/// screen applications (i.e. not something like Neovim), the color will not
+/// screen applications (e.g. not something like Neovim), the color will not
/// be extended vertically if any of the following are true:
///
/// * The nearest row has any cells that have the default background color.
@@ -1096,21 +1138,52 @@ keybind: Keybinds = .{},
/// configuration `font-size` will be used.
@"window-inherit-font-size": bool = true,
+/// Configure a preference for window decorations. This setting specifies
+/// a _preference_; the actual OS, desktop environment, window manager, etc.
+/// may override this preference. Ghostty will do its best to respect this
+/// preference but it may not always be possible.
+///
/// Valid values:
///
-/// * `true`
-/// * `false` - windows won't have native decorations, i.e. titlebar and
-/// borders. On macOS this also disables tabs and tab overview.
+/// * `none` - All window decorations will be disabled. Titlebar,
+/// borders, etc. will not be shown. On macOS, this will also disable
+/// tabs (enforced by the system).
+///
+/// * `auto` - Automatically decide to use either client-side or server-side
+/// decorations based on the detected preferences of the current OS and
+/// desktop environment. This option usually makes Ghostty look the most
+/// "native" for your desktop.
+///
+/// * `client` - Prefer client-side decorations.
+///
+/// * `server` - Prefer server-side decorations. This is only relevant
+/// on Linux with GTK, either on X11, or Wayland on a compositor that
+/// supports the `org_kde_kwin_server_decoration` protocol (e.g. KDE Plasma,
+/// but almost any non-GNOME desktop supports this protocol).
+///
+/// If `server` is set but the environment doesn't support server-side
+/// decorations, client-side decorations will be used instead.
+///
+/// The default value is `auto`.
+///
+/// For the sake of backwards compatibility and convenience, this setting also
+/// accepts boolean true and false values. If set to `true`, this is equivalent
+/// to `auto`. If set to `false`, this is equivalent to `none`.
+/// This is convenient for users who live primarily on systems that don't
+/// differentiate between client and server-side decorations (e.g. macOS and
+/// Windows).
///
/// The "toggle_window_decorations" keybind action can be used to create
-/// a keybinding to toggle this setting at runtime.
+/// a keybinding to toggle this setting at runtime. This will always toggle
+/// back to "auto" if the current value is "none" (this is an issue
+/// that will be fixed in the future).
///
/// Changing this configuration in your configuration and reloading will
/// only affect new windows. Existing windows will not be affected.
///
/// macOS: To hide the titlebar without removing the native window borders
/// or rounded corners, use `macos-titlebar-style = hidden` instead.
-@"window-decoration": bool = true,
+@"window-decoration": WindowDecoration = .auto,
/// The font that will be used for the application's window and tab titles.
///
@@ -1150,12 +1223,16 @@ keybind: Keybinds = .{},
/// This is currently only supported on macOS and Linux.
@"window-theme": WindowTheme = .auto,
-/// The colorspace to use for the terminal window. The default is `srgb` but
-/// this can also be set to `display-p3` to use the Display P3 colorspace.
+/// The color space to use when interpreting terminal colors. "Terminal colors"
+/// refers to colors specified in your configuration and colors produced by
+/// direct-color SGR sequences.
///
-/// Changing this value at runtime will only affect new windows.
+/// Valid values:
+///
+/// * `srgb` - Interpret colors in the sRGB color space. This is the default.
+/// * `display-p3` - Interpret colors in the Display P3 color space.
///
-/// This setting is only supported on macOS.
+/// This setting is currently only supported on macOS.
@"window-colorspace": WindowColorspace = .srgb,
/// The initial window size. This size is in terminal grid cells by default.
@@ -1333,7 +1410,7 @@ keybind: Keybinds = .{},
@"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms },
/// If true, when there are multiple split panes, the mouse selects the pane
-/// that is focused. This only applies to the currently focused window; i.e.
+/// that is focused. This only applies to the currently focused window; e.g.
/// mousing over a split in an unfocused window will not focus that split
/// and bring the window to front.
///
@@ -1377,7 +1454,7 @@ keybind: Keybinds = .{},
/// and a minor amount of user interaction).
@"title-report": bool = false,
-/// The total amount of bytes that can be used for image data (i.e. the Kitty
+/// The total amount of bytes that can be used for image data (e.g. the Kitty
/// image protocol) per terminal screen. The maximum value is 4,294,967,295
/// (4GiB). The default is 320MB. If this is set to zero, then all image
/// protocols will be disabled.
@@ -1608,7 +1685,9 @@ keybind: Keybinds = .{},
/// The default value is `detect`.
@"shell-integration": ShellIntegration = .detect,
-/// Shell integration features to enable if shell integration itself is enabled.
+/// Shell integration features to enable. These require our shell integration
+/// to be loaded, either automatically via shell-integration or manually.
+///
/// The format of this is a list of features to enable separated by commas. If
/// you prefix a feature with `no-` then it is disabled. If you omit a feature,
/// its default value is used, so you must explicitly disable features you don't
@@ -1637,7 +1716,7 @@ keybind: Keybinds = .{},
///
/// * `none` - OSC 4/10/11 queries receive no reply
///
-/// * `8-bit` - Color components are return unscaled, i.e. `rr/gg/bb`
+/// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb`
///
/// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb`
///
@@ -1698,6 +1777,31 @@ keybind: Keybinds = .{},
/// open terminals.
@"custom-shader-animation": CustomShaderAnimation = .true,
+/// Control the in-app notifications that Ghostty shows.
+///
+/// On Linux (GTK) with Adwaita, in-app notifications show up as toasts. Toasts
+/// appear overlaid on top of the terminal window. They are used to show
+/// information that is not critical but may be important.
+///
+/// Possible notifications are:
+///
+/// - `clipboard-copy` (default: true) - Show a notification when text is copied
+/// to the clipboard.
+///
+/// To specify a notification to enable, specify the name of the notification.
+/// To specify a notification to disable, prefix the name with `no-`. For
+/// example, to disable `clipboard-copy`, set this configuration to
+/// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`.
+///
+/// Multiple notifications can be enabled or disabled by separating them
+/// with a comma.
+///
+/// A value of "false" will disable all notifications. A value of "true" will
+/// enable all notifications.
+///
+/// This configuration only applies to GTK with Adwaita enabled.
+@"app-notifications": AppNotifications = .{},
+
/// If anything other than false, fullscreen mode on macOS will not use the
/// native fullscreen, but make the window fullscreen without animations and
/// using a new space. It's faster than the native fullscreen mode since it
@@ -1736,7 +1840,7 @@ keybind: Keybinds = .{},
/// typical for a macOS application and may not work well with all themes.
///
/// The "transparent" style will also update in real-time to dynamic
-/// changes to the window background color, i.e. via OSC 11. To make this
+/// changes to the window background color, e.g. via OSC 11. To make this
/// more aesthetically pleasing, this only happens if the terminal is
/// a window, tab, or split that borders the top of the window. This
/// avoids a disjointed appearance where the titlebar color changes
@@ -1752,9 +1856,12 @@ keybind: Keybinds = .{},
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
-/// existing windows in buggy ways. The top titlebar area of the window will
-/// continue to drag the window around and you will not be able to use
-/// the mouse for terminal events in this space.
+/// existing windows in buggy ways.
+///
+/// When "hidden", the top titlebar area can no longer be used for dragging
+/// the window. To drag the window, you can use option+click on the resizable
+/// areas of the frame to drag the window. This is a standard macOS behavior
+/// and not something Ghostty enables.
///
/// The default value is "transparent". This is an opinionated choice
/// but its one I think is the most aesthetically pleasing and works in
@@ -1803,7 +1910,7 @@ keybind: Keybinds = .{},
/// - U.S. International
///
/// Note that if an *Option*-sequence doesn't produce a printable character, it
-/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`).
+/// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`).
///
/// Explicit values that can be set:
///
@@ -1865,6 +1972,9 @@ keybind: Keybinds = .{},
/// Valid values:
///
/// * `official` - Use the official Ghostty icon.
+/// * `blueprint`, `chalkboard`, `microchip`, `glass`, `holographic`,
+/// `paper`, `retro`, `xray` - Official variants of the Ghostty icon
+/// hand-created by artists (no AI).
/// * `custom-style` - Use the official Ghostty icon but with custom
/// styles applied to various layers. The custom styles must be
/// specified using the additional `macos-icon`-prefixed configurations.
@@ -2027,6 +2137,10 @@ keybind: Keybinds = .{},
/// title bar, or you can switch tabs with keybinds.
@"gtk-tabs-location": GtkTabsLocation = .top,
+/// If this is `true`, the titlebar will be hidden when the window is maximized,
+/// and shown when the titlebar is unmaximized. GTK only.
+@"gtk-titlebar-hide-when-maximized": bool = false,
+
/// Determines the appearance of the top and bottom bars when using the
/// Adwaita tab bar. This requires `gtk-adwaita` to be enabled (it is
/// by default).
@@ -2041,29 +2155,6 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised,
-/// Control the toasts that Ghostty shows. Toasts are small notifications
-/// that appear overlaid on top of the terminal window. They are used to
-/// show information that is not critical but may be important.
-///
-/// Possible toasts are:
-///
-/// - `clipboard-copy` (default: true) - Show a toast when text is copied
-/// to the clipboard.
-///
-/// To specify a toast to enable, specify the name of the toast. To specify
-/// a toast to disable, prefix the name with `no-`. For example, to disable
-/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`.
-/// To enable the clipboard-copy toast, set this configuration to
-/// `clipboard-copy`.
-///
-/// Multiple toasts can be enabled or disabled by separating them with a comma.
-///
-/// A value of "false" will disable all toasts. A value of "true" will
-/// enable all toasts.
-///
-/// This configuration only applies to GTK with Adwaita enabled.
-@"adw-toast": AdwToast = .{},
-
/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs
/// are the new typical Gnome style where tabs fill their available space.
/// If you set this to `false` then tabs will only take up space they need,
@@ -2302,13 +2393,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) },
- .{ .write_scrollback_file = .paste },
+ .{ .write_screen_file = .paste },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) },
- .{ .write_scrollback_file = .open },
+ .{ .write_screen_file = .open },
);
// Expand Selection
@@ -5631,6 +5722,14 @@ pub const MacTitlebarProxyIcon = enum {
/// format at all.
pub const MacAppIcon = enum {
official,
+ blueprint,
+ chalkboard,
+ microchip,
+ glass,
+ holographic,
+ paper,
+ retro,
+ xray,
@"custom-style",
};
@@ -5665,8 +5764,8 @@ pub const AdwToolbarStyle = enum {
@"raised-border",
};
-/// See adw-toast
-pub const AdwToast = packed struct {
+/// See app-notifications
+pub const AppNotifications = packed struct {
@"clipboard-copy": bool = true,
};
@@ -5744,6 +5843,20 @@ pub const GraphemeWidthMethod = enum {
unicode,
};
+/// See alpha-blending
+pub const AlphaBlending = enum {
+ native,
+ linear,
+ @"linear-corrected",
+
+ pub fn isLinear(self: AlphaBlending) bool {
+ return switch (self) {
+ .native => false,
+ .linear, .@"linear-corrected" => true,
+ };
+ }
+};
+
/// See freetype-load-flag
pub const FreetypeLoadFlags = packed struct {
// The defaults here at the time of writing this match the defaults
@@ -5769,7 +5882,7 @@ pub const AutoUpdate = enum {
download,
};
-/// See background-blur-radius
+/// See background-blur
pub const BackgroundBlur = union(enum) {
false,
true,
@@ -5841,6 +5954,62 @@ pub const BackgroundBlur = union(enum) {
}
};
+/// See window-decoration
+pub const WindowDecoration = enum {
+ auto,
+ client,
+ server,
+ none,
+
+ pub fn parseCLI(input_: ?[]const u8) !WindowDecoration {
+ const input = input_ orelse return .auto;
+
+ return if (cli.args.parseBool(input)) |b|
+ if (b) .auto else .none
+ else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v|
+ v
+ else
+ error.InvalidValue;
+ }
+
+ test "parse WindowDecoration" {
+ const testing = std.testing;
+
+ {
+ const v = try WindowDecoration.parseCLI(null);
+ try testing.expectEqual(WindowDecoration.auto, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("true");
+ try testing.expectEqual(WindowDecoration.auto, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("false");
+ try testing.expectEqual(WindowDecoration.none, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("server");
+ try testing.expectEqual(WindowDecoration.server, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("client");
+ try testing.expectEqual(WindowDecoration.client, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("auto");
+ try testing.expectEqual(WindowDecoration.auto, v);
+ }
+ {
+ const v = try WindowDecoration.parseCLI("none");
+ try testing.expectEqual(WindowDecoration.none, v);
+ }
+ {
+ try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI(""));
+ try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa"));
+ }
+ }
+};
+
/// See theme
pub const Theme = struct {
light: []const u8,
diff --git a/src/config/c_get.zig b/src/config/c_get.zig
index 6804b0ae0c..251a95e772 100644
--- a/src/config/c_get.zig
+++ b/src/config/c_get.zig
@@ -192,21 +192,21 @@ test "c_get: background-blur" {
defer c.deinit();
{
- c.@"background-blur-radius" = .false;
+ c.@"background-blur" = .false;
var cval: u8 = undefined;
- try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
+ try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
- c.@"background-blur-radius" = .true;
+ c.@"background-blur" = .true;
var cval: u8 = undefined;
- try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
+ try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
- c.@"background-blur-radius" = .{ .radius = 42 };
+ c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
- try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
+ try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
}
diff --git a/src/config/theme.zig b/src/config/theme.zig
index b851ec3d46..2d206e1f64 100644
--- a/src/config/theme.zig
+++ b/src/config/theme.zig
@@ -104,6 +104,10 @@ pub const LocationIterator = struct {
/// Due to the way allocations are handled, an Arena allocator (or another
/// similar allocator implementation) should be used. It may not be safe to
/// free the returned allocations.
+///
+/// This will never return anything other than a handle to a regular file. If
+/// the theme resolves to something other than a regular file a diagnostic entry
+/// will be added to the list and null will be returned.
pub fn open(
arena_alloc: Allocator,
theme: []const u8,
@@ -119,6 +123,29 @@ pub fn open(
theme,
diags,
) orelse return null;
+ const stat = file.stat() catch |err| {
+ try diags.append(arena_alloc, .{
+ .message = try std.fmt.allocPrintZ(
+ arena_alloc,
+ "not reading theme from \"{s}\": {}",
+ .{ theme, err },
+ ),
+ });
+ return null;
+ };
+ switch (stat.kind) {
+ .file => {},
+ else => {
+ try diags.append(arena_alloc, .{
+ .message = try std.fmt.allocPrintZ(
+ arena_alloc,
+ "not reading theme from \"{s}\": it is a {s}",
+ .{ theme, @tagName(stat.kind) },
+ ),
+ });
+ return null;
+ },
+ }
return .{ .path = theme, .file = file };
}
@@ -140,9 +167,34 @@ pub fn open(
const cwd = std.fs.cwd();
while (try it.next()) |loc| {
const path = try std.fs.path.join(arena_alloc, &.{ loc.dir, theme });
- if (cwd.openFile(path, .{})) |file| return .{
- .path = path,
- .file = file,
+ if (cwd.openFile(path, .{})) |file| {
+ const stat = file.stat() catch |err| {
+ try diags.append(arena_alloc, .{
+ .message = try std.fmt.allocPrintZ(
+ arena_alloc,
+ "not reading theme from \"{s}\": {}",
+ .{ theme, err },
+ ),
+ });
+ return null;
+ };
+ switch (stat.kind) {
+ .file => {},
+ else => {
+ try diags.append(arena_alloc, .{
+ .message = try std.fmt.allocPrintZ(
+ arena_alloc,
+ "not reading theme from \"{s}\": it is a {s}",
+ .{ theme, @tagName(stat.kind) },
+ ),
+ });
+ return null;
+ },
+ }
+ return .{
+ .path = path,
+ .file = file,
+ };
} else |err| switch (err) {
// Not an error, just continue to the next location.
error.FileNotFound => {},
diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig
index 6661295f39..3749b48241 100644
--- a/src/font/face/coretext.zig
+++ b/src/font/face/coretext.zig
@@ -343,13 +343,12 @@ pub const Face = struct {
} = if (!self.isColorGlyph(glyph_index)) .{
.color = false,
.depth = 1,
- .space = try macos.graphics.ColorSpace.createDeviceGray(),
- .context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) &
- @intFromEnum(macos.graphics.ImageAlphaInfo.only),
+ .space = try macos.graphics.ColorSpace.createNamed(.linearGray),
+ .context_opts = @intFromEnum(macos.graphics.ImageAlphaInfo.only),
} else .{
.color = true,
.depth = 4,
- .space = try macos.graphics.ColorSpace.createDeviceRGB(),
+ .space = try macos.graphics.ColorSpace.createNamed(.displayP3),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) |
@intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first),
};
diff --git a/src/ghostty.zig b/src/ghostty.zig
new file mode 100644
index 0000000000..4cca32bd94
--- /dev/null
+++ b/src/ghostty.zig
@@ -0,0 +1,11 @@
+pub const apprt = @import("apprt.zig");
+pub const App = @import("App.zig");
+pub const global = @import("global.zig");
+pub const cli = @import("cli.zig");
+pub const input = @import("input.zig");
+pub const internal_os = @import("os/main.zig");
+pub const renderer = @import("renderer.zig");
+pub const terminal = @import("terminal/main.zig");
+pub const Surface = @import("Surface.zig");
+pub const config = @import("config.zig");
+pub const gl = @import("opengl");
diff --git a/src/global.zig b/src/global.zig
index c00ce27a4b..d5a7af630e 100644
--- a/src/global.zig
+++ b/src/global.zig
@@ -111,6 +111,9 @@ pub const GlobalState = struct {
}
}
+ // Setup our signal handlers before logging
+ initSignals();
+
// Output some debug information right away
std.log.info("ghostty version={s}", .{build_config.version_string});
std.log.info("ghostty build optimize={s}", .{build_config.mode_string});
@@ -175,6 +178,28 @@ pub const GlobalState = struct {
_ = value.deinit();
}
}
+
+ fn initSignals() void {
+ // Only posix systems.
+ if (comptime builtin.os.tag == .windows) return;
+
+ const p = std.posix;
+
+ var sa: p.Sigaction = .{
+ .handler = .{ .handler = p.SIG.IGN },
+ .mask = p.empty_sigset,
+ .flags = 0,
+ };
+
+ // We ignore SIGPIPE because it is a common signal we may get
+ // due to how we implement termio. When a terminal is closed we
+ // often write to a broken pipe to exit the read thread. This should
+ // be fixed one day but for now this helps make this a bit more
+ // robust.
+ p.sigaction(p.SIG.PIPE, &sa, null) catch |err| {
+ std.log.warn("failed to ignore SIGPIPE err={}", .{err});
+ };
+ }
};
/// Maintains the Unix resource limits that we set for our process. This
diff --git a/src/input/Binding.zig b/src/input/Binding.zig
index 2fdbc4cbaa..a6ffa662d7 100644
--- a/src/input/Binding.zig
+++ b/src/input/Binding.zig
@@ -236,9 +236,9 @@ pub const Action = union(enum) {
/// Send an `ESC` sequence.
esc: []const u8,
- // Send the given text. Uses Zig string literal syntax. This is currently
- // not validated. If the text is invalid (i.e. contains an invalid escape
- // sequence), the error will currently only show up in logs.
+ /// Send the given text. Uses Zig string literal syntax. This is currently
+ /// not validated. If the text is invalid (i.e. contains an invalid escape
+ /// sequence), the error will currently only show up in logs.
text: []const u8,
/// Send data to the pty depending on whether cursor key mode is enabled
@@ -284,8 +284,15 @@ pub const Action = union(enum) {
scroll_page_fractional: f32,
scroll_page_lines: i16,
- /// Adjust an existing selection in a given direction. This action
- /// does nothing if there is no active selection.
+ /// Adjust the current selection in a given direction. Does nothing if no
+ /// selection exists.
+ ///
+ /// Arguments:
+ /// - left, right, up, down, page_up, page_down, home, end,
+ /// beginning_of_line, end_of_line
+ ///
+ /// Example: Extend selection to the right
+ /// keybind = shift+right=adjust_selection:right
adjust_selection: AdjustSelection,
/// Jump the viewport forward or back by prompt. Positive number is the
@@ -341,26 +348,47 @@ pub const Action = union(enum) {
/// This only works with libadwaita enabled currently.
toggle_tab_overview: void,
- /// Create a new split in the given direction. The new split will appear in
- /// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
+ /// Create a new split in the given direction.
+ ///
+ /// Arguments:
+ /// - right, down, left, up, auto (splits along the larger direction)
+ ///
+ /// Example: Create split on the right
+ /// keybind = cmd+shift+d=new_split:right
new_split: SplitDirection,
- /// Focus on a split in a given direction. For example `goto_split:up`.
- /// Valid values are left, right, up, down, previous and next.
+ /// Focus a split in a given direction.
+ ///
+ /// Arguments:
+ /// - previous, next, up, left, down, right
+ ///
+ /// Example: Focus split on the right
+ /// keybind = cmd+right=goto_split:right
goto_split: SplitFocusDirection,
/// zoom/unzoom the current split.
toggle_split_zoom: void,
- /// Resize the current split by moving the split divider in the given
- /// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right.
+ /// Resize the current split in a given direction.
+ ///
+ /// Arguments:
+ /// - up, down, left, right
+ /// - the number of pixels to resize the split by
+ ///
+ /// Example: Move divider up 10 pixels
+ /// keybind = cmd+shift+up=resize_split:up,10
resize_split: SplitResizeParameter,
/// Equalize all splits in the current window
equalize_splits: void,
- /// Show, hide, or toggle the terminal inspector for the currently focused
- /// terminal.
+ /// Control the terminal inspector visibility.
+ ///
+ /// Arguments:
+ /// - toggle, show, hide
+ ///
+ /// Example: Toggle inspector visibility
+ /// keybind = cmd+i=inspector:toggle
inspector: InspectorMode,
/// Open the configuration file in the default OS editor. If your default OS
@@ -391,6 +419,9 @@ pub const Action = union(enum) {
/// This only works for macOS currently.
close_all_windows: void,
+ /// Toggle maximized window state. This only works on Linux.
+ toggle_maximize: void,
+
/// Toggle fullscreen mode of window.
toggle_fullscreen: void,
@@ -416,7 +447,7 @@ pub const Action = union(enum) {
/// is preserved between appearances, so you can always press the keybinding
/// to bring it back up.
///
- /// To enable the quick terminally globally so that Ghostty doesn't
+ /// To enable the quick terminal globally so that Ghostty doesn't
/// have to be focused, prefix your keybind with `global`. Example:
///
/// ```ini
@@ -444,6 +475,8 @@ pub const Action = union(enum) {
/// Ghostty becomes focused. When hiding all windows, focus is yielded
/// to the next application as determined by the OS.
///
+ /// Note: When the focused surface is fullscreen, this method does nothing.
+ ///
/// This currently only works on macOS.
toggle_visibility: void,
@@ -513,7 +546,6 @@ pub const Action = union(enum) {
pub const SplitFocusDirection = enum {
previous,
next,
-
up,
left,
down,
@@ -737,6 +769,7 @@ pub const Action = union(enum) {
.close_surface,
.close_tab,
.close_window,
+ .toggle_maximize,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,
@@ -1204,6 +1237,13 @@ pub const Set = struct {
/// This is a conscious decision since the primary use case of the reverse
/// map is to support GUI toolkit keyboard accelerators and no mainstream
/// GUI toolkit supports sequences.
+ ///
+ /// Performable triggers are also not present in the reverse map. This
+ /// is so that GUI toolkits don't register performable triggers as
+ /// menu shortcuts (the primary use case of the reverse map). GUI toolkits
+ /// such as GTK handle menu shortcuts too early in the event lifecycle
+ /// for performable to work so this is a conscious decision to ease the
+ /// integration with GUI toolkits.
reverse: ReverseMap = .{},
/// The entry type for the forward mapping of trigger to action.
@@ -1468,6 +1508,11 @@ pub const Set = struct {
// unbind should never go into the set, it should be handled prior
assert(action != .unbind);
+ // This is true if we're going to track this entry as
+ // a reverse mapping. There are certain scenarios we don't.
+ // See the reverse map docs for more information.
+ const track_reverse: bool = !flags.performable;
+
const gop = try self.bindings.getOrPut(alloc, t);
if (gop.found_existing) switch (gop.value_ptr.*) {
@@ -1479,7 +1524,7 @@ pub const Set = struct {
// If we have an existing binding for this trigger, we have to
// update the reverse mapping to remove the old action.
- .leaf => {
+ .leaf => if (track_reverse) {
const t_hash = t.hash();
var it = self.reverse.iterator();
while (it.next()) |reverse_entry| it: {
@@ -1496,8 +1541,9 @@ pub const Set = struct {
.flags = flags,
} };
errdefer _ = self.bindings.remove(t);
- try self.reverse.put(alloc, action, t);
- errdefer _ = self.reverse.remove(action);
+
+ if (track_reverse) try self.reverse.put(alloc, action, t);
+ errdefer if (track_reverse) self.reverse.remove(action);
}
/// Get a binding for a given trigger.
@@ -2347,6 +2393,39 @@ test "set: maintains reverse mapping" {
}
}
+test "set: performable is not part of reverse mappings" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s: Set = .{};
+ defer s.deinit(alloc);
+
+ try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
+ {
+ const trigger = s.getTrigger(.{ .new_window = {} }).?;
+ try testing.expect(trigger.key.translated == .a);
+ }
+
+ // trigger should be non-performable
+ try s.putFlags(
+ alloc,
+ .{ .key = .{ .translated = .b } },
+ .{ .new_window = {} },
+ .{ .performable = true },
+ );
+ {
+ const trigger = s.getTrigger(.{ .new_window = {} }).?;
+ try testing.expect(trigger.key.translated == .a);
+ }
+
+ // removal of performable should do nothing
+ s.remove(alloc, .{ .key = .{ .translated = .b } });
+ {
+ const trigger = s.getTrigger(.{ .new_window = {} }).?;
+ try testing.expect(trigger.key.translated == .a);
+ }
+}
+
test "set: overriding a mapping updates reverse" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/src/input/helpgen_actions.zig b/src/input/helpgen_actions.zig
new file mode 100644
index 0000000000..58305455b9
--- /dev/null
+++ b/src/input/helpgen_actions.zig
@@ -0,0 +1,113 @@
+//! This module is a help generator for keybind actions documentation.
+//! It can generate documentation in different formats (plaintext for CLI,
+//! markdown for website) while maintaining consistent content.
+
+const std = @import("std");
+const KeybindAction = @import("Binding.zig").Action;
+const help_strings = @import("help_strings");
+
+/// Format options for generating keybind actions documentation
+pub const Format = enum {
+ /// Plain text output with indentation
+ plaintext,
+ /// Markdown formatted output
+ markdown,
+
+ fn formatFieldName(self: Format, writer: anytype, field_name: []const u8) !void {
+ switch (self) {
+ .plaintext => {
+ try writer.writeAll(field_name);
+ try writer.writeAll(":\n");
+ },
+ .markdown => {
+ try writer.writeAll("## `");
+ try writer.writeAll(field_name);
+ try writer.writeAll("`\n");
+ },
+ }
+ }
+
+ fn formatDocLine(self: Format, writer: anytype, line: []const u8) !void {
+ switch (self) {
+ .plaintext => {
+ try writer.appendSlice(" ");
+ try writer.appendSlice(line);
+ try writer.appendSlice("\n");
+ },
+ .markdown => {
+ try writer.appendSlice(line);
+ try writer.appendSlice("\n");
+ },
+ }
+ }
+
+ fn header(self: Format) ?[]const u8 {
+ return switch (self) {
+ .plaintext => null,
+ .markdown =>
+ \\---
+ \\title: Keybinding Action Reference
+ \\description: Reference of all Ghostty keybinding actions.
+ \\editOnGithubLink: https://github.com/ghostty-org/ghostty/edit/main/src/input/Binding.zig
+ \\---
+ \\
+ \\This is a reference of all Ghostty keybinding actions.
+ \\
+ \\
+ ,
+ };
+ }
+};
+
+/// Generate keybind actions documentation with the specified format
+pub fn generate(
+ writer: anytype,
+ format: Format,
+ show_docs: bool,
+ page_allocator: std.mem.Allocator,
+) !void {
+ if (format.header()) |header| {
+ try writer.writeAll(header);
+ }
+
+ var buffer = std.ArrayList(u8).init(page_allocator);
+ defer buffer.deinit();
+
+ const fields = @typeInfo(KeybindAction).Union.fields;
+ inline for (fields) |field| {
+ if (field.name[0] == '_') continue;
+
+ // Write previously stored doc comment below all related actions
+ if (show_docs and @hasDecl(help_strings.KeybindAction, field.name)) {
+ try writer.writeAll(buffer.items);
+ try writer.writeAll("\n");
+
+ buffer.clearRetainingCapacity();
+ }
+
+ if (show_docs) {
+ try format.formatFieldName(writer, field.name);
+ } else {
+ try writer.writeAll(field.name);
+ try writer.writeAll("\n");
+ }
+
+ if (show_docs and @hasDecl(help_strings.KeybindAction, field.name)) {
+ var iter = std.mem.splitScalar(
+ u8,
+ @field(help_strings.KeybindAction, field.name),
+ '\n',
+ );
+ while (iter.next()) |s| {
+ // If it is the last line and empty, then skip it.
+ if (iter.peek() == null and s.len == 0) continue;
+ try format.formatDocLine(&buffer, s);
+ }
+ }
+ }
+
+ // Write any remaining buffered documentation
+ if (buffer.items.len > 0) {
+ try writer.writeAll(buffer.items);
+ }
+}
diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig
index 54d49b0883..1824f5eadc 100644
--- a/src/inspector/Inspector.zig
+++ b/src/inspector/Inspector.zig
@@ -53,6 +53,22 @@ key_events: inspector.key.EventRing,
vt_events: inspector.termio.VTEventRing,
vt_stream: inspector.termio.Stream,
+/// The currently selected event sequence number for keyboard navigation
+selected_event_seq: ?u32 = null,
+
+/// Flag indicating whether we need to scroll to the selected item
+need_scroll_to_selected: bool = false,
+
+/// Flag indicating whether the selection was made by keyboard
+is_keyboard_selection: bool = false,
+
+/// Enum representing keyboard navigation actions
+const KeyAction = enum {
+ down,
+ none,
+ up,
+};
+
const CellInspect = union(enum) {
/// Idle, no cell inspection is requested
idle: void,
@@ -1014,6 +1030,24 @@ fn renderKeyboardWindow(self: *Inspector) void {
} // table
}
+/// Helper function to check keyboard state and determine navigation action.
+fn getKeyAction(self: *Inspector) KeyAction {
+ _ = self;
+ const keys = .{
+ .{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down },
+ .{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down },
+ .{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up },
+ .{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up },
+ };
+
+ inline for (keys) |k| {
+ if (cimgui.c.igIsKeyPressed_Bool(k.key, false)) {
+ return k.action;
+ }
+ }
+ return .none;
+}
+
fn renderTermioWindow(self: *Inspector) void {
// Start our window. If we're collapsed we do nothing.
defer cimgui.c.igEnd();
@@ -1090,6 +1124,60 @@ fn renderTermioWindow(self: *Inspector) void {
0,
);
+ // Handle keyboard navigation when window is focused
+ if (cimgui.c.igIsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) {
+ const key_pressed = self.getKeyAction();
+
+ switch (key_pressed) {
+ .none => {},
+ .up, .down => {
+ // If no event is selected, select the first/last event based on direction
+ if (self.selected_event_seq == null) {
+ if (!self.vt_events.empty()) {
+ var it = self.vt_events.iterator(if (key_pressed == .up) .forward else .reverse);
+ if (it.next()) |ev| {
+ self.selected_event_seq = @as(u32, @intCast(ev.seq));
+ }
+ }
+ } else {
+ // Find next/previous event based on current selection
+ var it = self.vt_events.iterator(.reverse);
+ switch (key_pressed) {
+ .down => {
+ var found = false;
+ while (it.next()) |ev| {
+ if (found) {
+ self.selected_event_seq = @as(u32, @intCast(ev.seq));
+ break;
+ }
+ if (ev.seq == self.selected_event_seq.?) {
+ found = true;
+ }
+ }
+ },
+ .up => {
+ var prev_ev: ?*const inspector.termio.VTEvent = null;
+ while (it.next()) |ev| {
+ if (ev.seq == self.selected_event_seq.?) {
+ if (prev_ev) |prev| {
+ self.selected_event_seq = @as(u32, @intCast(prev.seq));
+ break;
+ }
+ }
+ prev_ev = ev;
+ }
+ },
+ .none => unreachable,
+ }
+ }
+
+ // Mark that we need to scroll to the newly selected item
+ self.need_scroll_to_selected = true;
+ self.is_keyboard_selection = true;
+ },
+ }
+ }
+
var it = self.vt_events.iterator(.reverse);
while (it.next()) |ev| {
// Need to push an ID so that our selectable is unique.
@@ -1098,12 +1186,32 @@ fn renderTermioWindow(self: *Inspector) void {
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
_ = cimgui.c.igTableNextColumn();
- _ = cimgui.c.igSelectable_BoolPtr(
+
+ // Store the previous selection state to detect changes
+ const was_selected = ev.imgui_selected;
+
+ // Update selection state based on keyboard navigation
+ if (self.selected_event_seq) |seq| {
+ ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq);
+ }
+
+ // Handle selectable widget
+ if (cimgui.c.igSelectable_BoolPtr(
"##select",
&ev.imgui_selected,
cimgui.c.ImGuiSelectableFlags_SpanAllColumns,
.{ .x = 0, .y = 0 },
- );
+ )) {
+ // If selection state changed, update keyboard navigation state
+ if (ev.imgui_selected != was_selected) {
+ self.selected_event_seq = if (ev.imgui_selected)
+ @as(u32, @intCast(ev.seq))
+ else
+ null;
+ self.is_keyboard_selection = false;
+ }
+ }
+
cimgui.c.igSameLine(0, 0);
cimgui.c.igText("%d", ev.seq);
_ = cimgui.c.igTableNextColumn();
@@ -1159,6 +1267,12 @@ fn renderTermioWindow(self: *Inspector) void {
cimgui.c.igText("%s", entry.value_ptr.ptr);
}
}
+
+ // If this is the selected event and scrolling is needed, scroll to it
+ if (self.need_scroll_to_selected and self.is_keyboard_selection) {
+ cimgui.c.igSetScrollHereY(0.5);
+ self.need_scroll_to_selected = false;
+ }
}
}
} // table
diff --git a/src/main.zig b/src/main.zig
index ecf38fbb30..121a3b7d20 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -9,6 +9,7 @@ const entrypoint = switch (build_config.exe_entrypoint) {
.mdgen_ghostty_5 => @import("build/mdgen/main_ghostty_5.zig"),
.webgen_config => @import("build/webgen/main_config.zig"),
.webgen_actions => @import("build/webgen/main_actions.zig"),
+ .webgen_commands => @import("build/webgen/main_commands.zig"),
.bench_parser => @import("bench/parser.zig"),
.bench_stream => @import("bench/stream.zig"),
.bench_codepoint_width => @import("bench/codepoint-width.zig"),
diff --git a/src/noop.zig b/src/noop.zig
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/options.zig b/src/options.zig
new file mode 100644
index 0000000000..1125d31c38
--- /dev/null
+++ b/src/options.zig
@@ -0,0 +1,29 @@
+const Metal = @import("renderer/Metal.zig");
+const OpenGL = @import("renderer/OpenGL.zig");
+const WebGL = @import("renderer/WebGL.zig");
+const glfw = @import("apprt/glfw.zig");
+const gtk = @import("apprt/gtk.zig");
+const none = @import("apprt/none.zig");
+const browser = @import("apprt/browser.zig");
+const embedded = @import("apprt/embedded.zig");
+const build_config = @import("build_config.zig");
+const root = @import("root");
+
+/// Stdlib-wide options that can be overridden by the root file.
+pub const options: type = if (@hasDecl(root, "ghostty_options")) root.ghostty_options else if (@hasDecl(@import("options"), "ghostty_options")) @import("options").ghostty_options else Options;
+const Options = struct {
+ pub const Renderer = switch (build_config.renderer) {
+ .metal => Metal,
+ .opengl => OpenGL,
+ .webgl => WebGL,
+ };
+ pub const runtime = switch (build_config.artifact) {
+ .exe => switch (build_config.app_runtime) {
+ .none => none,
+ .glfw => glfw,
+ .gtk => gtk,
+ },
+ .lib => embedded,
+ .wasm_module => browser,
+ };
+};
diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig
index 0a66c59878..5645e337a1 100644
--- a/src/os/cgroup.zig
+++ b/src/os/cgroup.zig
@@ -77,7 +77,22 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
// Get a file descriptor that refers to the cgroup directory in the cgroup
// sysfs to pass to the kernel in clone3.
const fd: linux.fd_t = fd: {
- const rc = linux.open(path, linux.O{ .PATH = true, .DIRECTORY = true }, 0);
+ const rc = linux.open(
+ path,
+ .{
+ // Self-explanatory: we expect to open a directory, and
+ // we only need the path-level permissions.
+ .PATH = true,
+ .DIRECTORY = true,
+
+ // We don't want to leak this fd to the child process
+ // when we clone below since we're using this fd for
+ // a cgroup clone.
+ .CLOEXEC = true,
+ },
+ 0,
+ );
+
switch (posix.errno(rc)) {
.SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)),
else => |errno| {
@@ -87,6 +102,7 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
}
};
assert(fd >= 0);
+ defer _ = linux.close(fd);
const args: extern struct {
flags: u64,
@@ -115,7 +131,8 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
};
const rc = linux.syscall2(linux.SYS.clone3, @intFromPtr(&args), @sizeOf(@TypeOf(args)));
- return switch (posix.errno(rc)) {
+ // do not use posix.errno, when linking libc it will use the libc errno which will not be set when making the syscall directly
+ return switch (std.os.linux.E.init(rc)) {
.SUCCESS => @as(posix.pid_t, @intCast(rc)),
else => |errno| err: {
log.err("unable to clone: {}", .{errno});
diff --git a/src/os/env.zig b/src/os/env.zig
index cf6cc0fe75..1916053b32 100644
--- a/src/os/env.zig
+++ b/src/os/env.zig
@@ -2,6 +2,17 @@ const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const posix = std.posix;
+const isFlatpak = @import("flatpak.zig").isFlatpak;
+
+pub const Error = Allocator.Error;
+
+/// Get the environment map.
+pub fn getEnvMap(alloc: Allocator) !std.process.EnvMap {
+ return if (isFlatpak())
+ std.process.EnvMap.init(alloc)
+ else
+ try std.process.getEnvMap(alloc);
+}
/// Append a value to an environment variable such as PATH.
/// The returned value is always allocated so it must be freed.
@@ -9,7 +20,7 @@ pub fn appendEnv(
alloc: Allocator,
current: []const u8,
value: []const u8,
-) ![]u8 {
+) Error![]u8 {
// If there is no prior value, we return it as-is
if (current.len == 0) return try alloc.dupe(u8, value);
@@ -26,7 +37,7 @@ pub fn appendEnvAlways(
alloc: Allocator,
current: []const u8,
value: []const u8,
-) ![]u8 {
+) Error![]u8 {
return try std.fmt.allocPrint(alloc, "{s}{c}{s}", .{
current,
std.fs.path.delimiter,
@@ -40,7 +51,7 @@ pub fn prependEnv(
alloc: Allocator,
current: []const u8,
value: []const u8,
-) ![]u8 {
+) Error![]u8 {
// If there is no prior value, we return it as-is
if (current.len == 0) return try alloc.dupe(u8, value);
@@ -68,7 +79,7 @@ pub const GetEnvResult = struct {
/// This will allocate on Windows but not on other platforms. The returned
/// value should have deinit called to do the proper cleanup no matter what
/// platform you are on.
-pub fn getenv(alloc: Allocator, key: []const u8) !?GetEnvResult {
+pub fn getenv(alloc: Allocator, key: []const u8) Error!?GetEnvResult {
return switch (builtin.os.tag) {
// Non-Windows doesn't need to allocate
else => if (posix.getenv(key)) |v| .{ .value = v } else null,
@@ -78,7 +89,8 @@ pub fn getenv(alloc: Allocator, key: []const u8) !?GetEnvResult {
.value = v,
} else |err| switch (err) {
error.EnvironmentVariableNotFound => null,
- else => err,
+ error.InvalidWtf8 => null,
+ else => |e| e,
},
};
}
diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig
index faac4bd272..09570554ef 100644
--- a/src/os/flatpak.zig
+++ b/src/os/flatpak.zig
@@ -265,16 +265,12 @@ pub const FlatpakHostCommand = struct {
}
// Build our args
- const args_ptr = c.g_ptr_array_new();
- {
- errdefer _ = c.g_ptr_array_free(args_ptr, 1);
- for (self.argv) |arg| {
- const argZ = try arena.dupeZ(u8, arg);
- c.g_ptr_array_add(args_ptr, argZ.ptr);
- }
+ const args = try arena.alloc(?[*:0]u8, self.argv.len + 1);
+ for (0.., self.argv) |i, arg| {
+ const argZ = try arena.dupeZ(u8, arg);
+ args[i] = argZ.ptr;
}
- const args = c.g_ptr_array_free(args_ptr, 0);
- defer c.g_free(@as(?*anyopaque, @ptrCast(args)));
+ args[args.len - 1] = null;
// Get the cwd in case we don't have ours set. A small optimization
// would be to do this only if we need it but this isn't a
@@ -286,7 +282,7 @@ pub const FlatpakHostCommand = struct {
const params = c.g_variant_new(
"(^ay^aay@a{uh}@a{ss}u)",
@as(*const anyopaque, if (self.cwd) |*cwd| cwd.ptr else g_cwd),
- args,
+ args.ptr,
c.g_variant_builder_end(fd_builder),
c.g_variant_builder_end(env_builder),
@as(c_int, 0),
diff --git a/src/os/main.zig b/src/os/main.zig
index df6f894f50..cb93559315 100644
--- a/src/os/main.zig
+++ b/src/os/main.zig
@@ -26,6 +26,7 @@ pub const shell = @import("shell.zig");
// Functions and types
pub const CFReleaseThread = @import("cf_release_thread.zig");
pub const TempDir = @import("TempDir.zig");
+pub const getEnvMap = env.getEnvMap;
pub const appendEnv = env.appendEnv;
pub const appendEnvAlways = env.appendEnvAlways;
pub const prependEnv = env.prependEnv;
diff --git a/src/os/pipe.zig b/src/os/pipe.zig
index 392f720834..2cb7bd4a39 100644
--- a/src/os/pipe.zig
+++ b/src/os/pipe.zig
@@ -3,10 +3,11 @@ const builtin = @import("builtin");
const windows = @import("windows.zig");
const posix = std.posix;
-/// pipe() that works on Windows and POSIX.
+/// pipe() that works on Windows and POSIX. For POSIX systems, this sets
+/// CLOEXEC on the file descriptors.
pub fn pipe() ![2]posix.fd_t {
switch (builtin.os.tag) {
- else => return try posix.pipe(),
+ else => return try posix.pipe2(.{ .CLOEXEC = true }),
.windows => {
var read: windows.HANDLE = undefined;
var write: windows.HANDLE = undefined;
diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig
index c0f82dec5d..4ef256c1ac 100644
--- a/src/os/resourcesdir.zig
+++ b/src/os/resourcesdir.zig
@@ -21,7 +21,11 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 {
// This is the sentinel value we look for in the path to know
// we've found the resources directory.
- const sentinel = "terminfo/ghostty.termcap";
+ const sentinel = switch (comptime builtin.target.os.tag) {
+ .windows => "terminfo/ghostty.terminfo",
+ .macos => "terminfo/78/xterm-ghostty",
+ else => "terminfo/x/xterm-ghostty",
+ };
// Get the path to our running binary
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
diff --git a/src/pty.zig b/src/pty.zig
index c0d082411c..6f97e190d4 100644
--- a/src/pty.zig
+++ b/src/pty.zig
@@ -41,12 +41,16 @@ pub const Mode = packed struct {
// a termio that doesn't use a pty. This isn't used in any user-facing
// artifacts, this is just a stopgap to get compilation to work on iOS.
const NullPty = struct {
+ pub const Error = OpenError || GetModeError || SetSizeError || ChildPreExecError;
+
pub const Fd = posix.fd_t;
master: Fd,
slave: Fd,
- pub fn open(size: winsize) !Pty {
+ pub const OpenError = error{};
+
+ pub fn open(size: winsize) OpenError!Pty {
_ = size;
return .{ .master = 0, .slave = 0 };
}
@@ -55,17 +59,23 @@ const NullPty = struct {
_ = self;
}
- pub fn getMode(self: Pty) error{GetModeFailed}!Mode {
+ pub const GetModeError = error{GetModeFailed};
+
+ pub fn getMode(self: Pty) GetModeError!Mode {
_ = self;
return .{};
}
- pub fn setSize(self: *Pty, size: winsize) !void {
+ pub const SetSizeError = error{};
+
+ pub fn setSize(self: *Pty, size: winsize) SetSizeError!void {
_ = self;
_ = size;
}
- pub fn childPreExec(self: Pty) !void {
+ pub const ChildPreExecError = error{};
+
+ pub fn childPreExec(self: Pty) ChildPreExecError!void {
_ = self;
}
};
@@ -74,6 +84,8 @@ const NullPty = struct {
/// of Linux syscalls. The caller is responsible for detail-oriented handling
/// of the returned file handles.
const PosixPty = struct {
+ pub const Error = OpenError || GetModeError || GetSizeError || SetSizeError || ChildPreExecError;
+
pub const Fd = posix.fd_t;
// https://github.com/ziglang/zig/issues/13277
@@ -94,11 +106,16 @@ const PosixPty = struct {
};
/// The file descriptors for the master and slave side of the pty.
+ /// The slave side is never closed automatically by this struct
+ /// so the caller is responsible for closing it if things
+ /// go wrong.
master: Fd,
slave: Fd,
+ pub const OpenError = error{OpenptyFailed};
+
/// Open a new PTY with the given initial size.
- pub fn open(size: winsize) !Pty {
+ pub fn open(size: winsize) OpenError!Pty {
// Need to copy so that it becomes non-const.
var sizeCopy = size;
@@ -117,6 +134,24 @@ const PosixPty = struct {
_ = posix.system.close(slave_fd);
}
+ // Set CLOEXEC on the master fd, only the slave fd should be inherited
+ // by the child process (shell/command).
+ cloexec: {
+ const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| {
+ log.warn("error getting flags for master fd err={}", .{err});
+ break :cloexec;
+ };
+
+ _ = std.posix.fcntl(
+ master_fd,
+ std.posix.F.SETFD,
+ flags | std.posix.FD_CLOEXEC,
+ ) catch |err| {
+ log.warn("error setting CLOEXEC on master fd err={}", .{err});
+ break :cloexec;
+ };
+ }
+
// Enable UTF-8 mode. I think this is on by default on Linux but it
// is NOT on by default on macOS so we ensure that it is always set.
var attrs: c.termios = undefined;
@@ -126,7 +161,7 @@ const PosixPty = struct {
if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0)
return error.OpenptyFailed;
- return Pty{
+ return .{
.master = master_fd,
.slave = slave_fd,
};
@@ -134,11 +169,12 @@ const PosixPty = struct {
pub fn deinit(self: *Pty) void {
_ = posix.system.close(self.master);
- _ = posix.system.close(self.slave);
self.* = undefined;
}
- pub fn getMode(self: Pty) error{GetModeFailed}!Mode {
+ pub const GetModeError = error{GetModeFailed};
+
+ pub fn getMode(self: Pty) GetModeError!Mode {
var attrs: c.termios = undefined;
if (c.tcgetattr(self.master, &attrs) != 0)
return error.GetModeFailed;
@@ -149,8 +185,10 @@ const PosixPty = struct {
};
}
+ pub const GetSizeError = error{IoctlFailed};
+
/// Return the size of the pty.
- pub fn getSize(self: Pty) !winsize {
+ pub fn getSize(self: Pty) GetSizeError!winsize {
var ws: winsize = undefined;
if (c.ioctl(self.master, TIOCGWINSZ, @intFromPtr(&ws)) < 0)
return error.IoctlFailed;
@@ -158,15 +196,19 @@ const PosixPty = struct {
return ws;
}
+ pub const SetSizeError = error{IoctlFailed};
+
/// Set the size of the pty.
- pub fn setSize(self: *Pty, size: winsize) !void {
+ pub fn setSize(self: *Pty, size: winsize) SetSizeError!void {
if (c.ioctl(self.master, TIOCSWINSZ, @intFromPtr(&size)) < 0)
return error.IoctlFailed;
}
+ pub const ChildPreExecError = error{ OperationNotSupported, ProcessGroupFailed, SetControllingTerminalFailed };
+
/// This should be called prior to exec in the forked child process
/// in order to setup the tty properly.
- pub fn childPreExec(self: Pty) !void {
+ pub fn childPreExec(self: Pty) ChildPreExecError!void {
// Reset our signals
var sa: posix.Sigaction = .{
.handler = .{ .handler = posix.SIG.DFL },
@@ -181,6 +223,7 @@ const PosixPty = struct {
try posix.sigaction(posix.SIG.HUP, &sa, null);
try posix.sigaction(posix.SIG.ILL, &sa, null);
try posix.sigaction(posix.SIG.INT, &sa, null);
+ try posix.sigaction(posix.SIG.PIPE, &sa, null);
try posix.sigaction(posix.SIG.SEGV, &sa, null);
try posix.sigaction(posix.SIG.TRAP, &sa, null);
try posix.sigaction(posix.SIG.TERM, &sa, null);
@@ -201,13 +244,13 @@ const PosixPty = struct {
// Can close master/slave pair now
posix.close(self.slave);
posix.close(self.master);
-
- // TODO: reset signals
}
};
/// Windows PTY creation and management.
const WindowsPty = struct {
+ pub const Error = OpenError || GetSizeError || SetSizeError;
+
pub const Fd = windows.HANDLE;
// Process-wide counter for pipe names
@@ -220,8 +263,10 @@ const WindowsPty = struct {
pseudo_console: windows.exp.HPCON,
size: winsize,
+ pub const OpenError = error{Unexpected};
+
/// Open a new PTY with the given initial size.
- pub fn open(size: winsize) !Pty {
+ pub fn open(size: winsize) OpenError!Pty {
var pty: Pty = undefined;
var pipe_path_buf: [128]u8 = undefined;
@@ -330,13 +375,17 @@ const WindowsPty = struct {
self.* = undefined;
}
+ pub const GetSizeError = error{};
+
/// Return the size of the pty.
- pub fn getSize(self: Pty) !winsize {
+ pub fn getSize(self: Pty) GetSizeError!winsize {
return self.size;
}
+ pub const SetSizeError = error{ResizeFailed};
+
/// Set the size of the pty.
- pub fn setSize(self: *Pty, size: winsize) !void {
+ pub fn setSize(self: *Pty, size: winsize) SetSizeError!void {
const result = windows.exp.kernel32.ResizePseudoConsole(
self.pseudo_console,
.{ .X = @intCast(size.ws_col), .Y = @intCast(size.ws_row) },
diff --git a/src/renderer.zig b/src/renderer.zig
index d968ab4dfb..f8b8968175 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -22,6 +22,7 @@ pub const WebGL = @import("renderer/WebGL.zig");
pub const Options = @import("renderer/Options.zig");
pub const Thread = @import("renderer/Thread.zig");
pub const State = @import("renderer/State.zig");
+pub const options = @import("options.zig").options;
pub const CursorStyle = cursor.Style;
pub const Message = message.Message;
pub const Size = size.Size;
@@ -55,11 +56,7 @@ pub const Impl = enum {
/// The implementation to use for the renderer. This is comptime chosen
/// so that every build has exactly one renderer implementation.
-pub const Renderer = switch (build_config.renderer) {
- .metal => Metal,
- .opengl => OpenGL,
- .webgl => WebGL,
-};
+pub const Renderer = options.Renderer;
/// The health status of a renderer. These must be shared across all
/// renderers even if some states aren't reachable so that our API users
diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig
index 09dafd1fc7..ed116a3d76 100644
--- a/src/renderer/Metal.zig
+++ b/src/renderer/Metal.zig
@@ -21,6 +21,7 @@ const renderer = @import("../renderer.zig");
const math = @import("../math.zig");
const Surface = @import("../Surface.zig");
const link = @import("link.zig");
+const graphics = macos.graphics;
const fgMode = @import("cell.zig").fgMode;
const isCovering = @import("cell.zig").isCovering;
const shadertoy = @import("shadertoy.zig");
@@ -105,10 +106,6 @@ default_cursor_color: ?terminal.color.RGB,
/// foreground color as the cursor color.
cursor_invert: bool,
-/// The current frame background color. This is only updated during
-/// the updateFrame method.
-current_background_color: terminal.color.RGB,
-
/// The current set of cells to render. This is rebuilt on every frame
/// but we keep this around so that we don't reallocate. Each set of
/// cells goes into a separate shader.
@@ -151,6 +148,9 @@ layer: objc.Object, // CAMetalLayer
/// a display link.
display_link: ?DisplayLink = null,
+/// The `CGColorSpace` that represents our current terminal color space
+terminal_colorspace: *graphics.ColorSpace,
+
/// Custom shader state. This is only set if we have custom shaders.
custom_shader_state: ?CustomShaderState = null,
@@ -182,15 +182,34 @@ pub const GPUState = struct {
/// This buffer is written exactly once so we can use it globally.
instance: InstanceBuffer, // MTLBuffer
+ /// The default storage mode to use for resources created with our device.
+ ///
+ /// This is based on whether the device is a discrete GPU or not, since
+ /// discrete GPUs do not have unified memory and therefore do not support
+ /// the "shared" storage mode, instead we have to use the "managed" mode.
+ default_storage_mode: mtl.MTLResourceOptions.StorageMode,
+
pub fn init() !GPUState {
const device = try chooseDevice();
const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
errdefer queue.release();
+ // We determine whether our device is a discrete GPU based on these:
+ // - We're on macOS (iOS, iPadOS, etc. are guaranteed to be integrated).
+ // - We're not on aarch64 (Apple Silicon, therefore integrated).
+ // - The device reports that it does not have unified memory.
+ const is_discrete =
+ builtin.target.os.tag == .macos and
+ builtin.target.cpu.arch != .aarch64 and
+ !device.getProperty(bool, "hasUnifiedMemory");
+
+ const default_storage_mode: mtl.MTLResourceOptions.StorageMode =
+ if (is_discrete) .managed else .shared;
+
var instance = try InstanceBuffer.initFill(device, &.{
0, 1, 3, // Top-left triangle
1, 2, 3, // Bottom-right triangle
- });
+ }, .{ .storage_mode = default_storage_mode });
errdefer instance.deinit();
var result: GPUState = .{
@@ -198,11 +217,12 @@ pub const GPUState = struct {
.queue = queue,
.instance = instance,
.frames = undefined,
+ .default_storage_mode = default_storage_mode,
};
// Initialize all of our frame state.
for (&result.frames) |*frame| {
- frame.* = try FrameState.init(result.device);
+ frame.* = try FrameState.init(result.device, default_storage_mode);
}
return result;
@@ -288,18 +308,47 @@ pub const FrameState = struct {
const CellBgBuffer = mtl_buffer.Buffer(mtl_shaders.CellBg);
const CellTextBuffer = mtl_buffer.Buffer(mtl_shaders.CellText);
- pub fn init(device: objc.Object) !FrameState {
+ pub fn init(
+ device: objc.Object,
+ /// Storage mode for buffers and textures.
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
+ ) !FrameState {
// Uniform buffer contains exactly 1 uniform struct. The
// uniform data will be undefined so this must be set before
// a frame is drawn.
- var uniforms = try UniformBuffer.init(device, 1);
+ var uniforms = try UniformBuffer.init(
+ device,
+ 1,
+ .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = storage_mode,
+ },
+ );
errdefer uniforms.deinit();
// Create the buffers for our vertex data. The preallocation size
// is likely too small but our first frame update will resize it.
- var cells = try CellTextBuffer.init(device, 10 * 10);
+ var cells = try CellTextBuffer.init(
+ device,
+ 10 * 10,
+ .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = storage_mode,
+ },
+ );
errdefer cells.deinit();
- var cells_bg = try CellBgBuffer.init(device, 10 * 10);
+ var cells_bg = try CellBgBuffer.init(
+ device,
+ 10 * 10,
+ .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = storage_mode,
+ },
+ );
+
errdefer cells_bg.deinit();
// Initialize our textures for our font atlas.
@@ -307,13 +356,13 @@ pub const FrameState = struct {
.data = undefined,
.size = 8,
.format = .grayscale,
- });
+ }, storage_mode);
errdefer grayscale.release();
const color = try initAtlasTexture(device, &.{
.data = undefined,
.size = 8,
.format = .rgba,
- });
+ }, storage_mode);
errdefer color.release();
return .{
@@ -390,6 +439,8 @@ pub const DerivedConfig = struct {
custom_shaders: configpkg.RepeatablePath,
links: link.Set,
vsync: bool,
+ colorspace: configpkg.Config.WindowColorspace,
+ blending: configpkg.Config.AlphaBlending,
pub fn init(
alloc_gpa: Allocator,
@@ -460,7 +511,8 @@ pub const DerivedConfig = struct {
.custom_shaders = custom_shaders,
.links = links,
.vsync = config.@"window-vsync",
-
+ .colorspace = config.@"window-colorspace",
+ .blending = config.@"alpha-blending",
.arena = arena,
};
}
@@ -490,10 +542,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
}
pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
- var arena = ArenaAllocator.init(alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
-
const ViewInfo = struct {
view: objc.Object,
scaleFactor: f64,
@@ -512,7 +560,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
nswindow.getProperty(?*anyopaque, "contentView").?,
);
const scaleFactor = nswindow.getProperty(
- macos.graphics.c.CGFloat,
+ graphics.c.CGFloat,
"backingScaleFactor",
);
@@ -553,6 +601,40 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
layer.setProperty("opaque", options.config.background_opacity >= 1);
layer.setProperty("displaySyncEnabled", options.config.vsync);
+ // Set our layer's pixel format appropriately.
+ layer.setProperty(
+ "pixelFormat",
+ // Using an `*_srgb` pixel format makes Metal gamma encode
+ // the pixels written to it *after* blending, which means
+ // we get linear alpha blending rather than gamma-incorrect
+ // blending.
+ if (options.config.blending.isLinear())
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
+ else
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
+ );
+
+ // Set our layer's color space to Display P3.
+ // This allows us to have "Apple-style" alpha blending,
+ // since it seems to be the case that Apple apps like
+ // Terminal and TextEdit render text in the display's
+ // color space using converted colors, which reduces,
+ // but does not fully eliminate blending artifacts.
+ const colorspace = try graphics.ColorSpace.createNamed(.displayP3);
+ defer colorspace.release();
+ layer.setProperty("colorspace", colorspace);
+
+ // Create a colorspace the represents our terminal colors
+ // this will allow us to create e.g. `CGColor`s for things
+ // like the current background color.
+ const terminal_colorspace = try graphics.ColorSpace.createNamed(
+ switch (options.config.colorspace) {
+ .@"display-p3" => .displayP3,
+ .srgb => .sRGB,
+ },
+ );
+ errdefer terminal_colorspace.release();
+
// Make our view layer-backed with our Metal layer. On iOS views are
// always layer backed so we don't need to do this. But on iOS the
// caller MUST be sure to set the layerClass to CAMetalLayer.
@@ -578,54 +660,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
});
errdefer font_shaper.deinit();
- // Load our custom shaders
- const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
- arena_alloc,
- options.config.custom_shaders,
- .msl,
- ) catch |err| err: {
- log.warn("error loading custom shaders err={}", .{err});
- break :err &.{};
- };
-
- // If we have custom shaders then setup our state
- var custom_shader_state: ?CustomShaderState = state: {
- if (custom_shaders.len == 0) break :state null;
-
- // Build our sampler for our texture
- var sampler = try mtl_sampler.Sampler.init(gpu_state.device);
- errdefer sampler.deinit();
-
- break :state .{
- // Resolution and screen textures will be fixed up by first
- // call to setScreenSize. Draw calls will bail out early if
- // the screen size hasn't been set yet, so it won't error.
- .front_texture = undefined,
- .back_texture = undefined,
- .sampler = sampler,
- .uniforms = .{
- .resolution = .{ 0, 0, 1 },
- .time = 1,
- .time_delta = 1,
- .frame_rate = 1,
- .frame = 1,
- .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- .mouse = .{ 0, 0, 0, 0 },
- .date = .{ 0, 0, 0, 0 },
- .sample_rate = 1,
- },
-
- .first_frame_time = try std.time.Instant.now(),
- .last_frame_time = try std.time.Instant.now(),
- };
- };
- errdefer if (custom_shader_state) |*state| state.deinit();
-
- // Initialize our shaders
- var shaders = try Shaders.init(alloc, gpu_state.device, custom_shaders);
- errdefer shaders.deinit(alloc);
-
// Initialize all the data that requires a critical font section.
const font_critical: struct {
metrics: font.Metrics,
@@ -661,7 +695,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.cursor_color = null,
.default_cursor_color = options.config.cursor_color,
.cursor_invert = options.config.cursor_invert,
- .current_background_color = options.config.background,
// Render state
.cells = .{},
@@ -674,7 +707,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.min_contrast = options.config.min_contrast,
.cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) },
.cursor_color = undefined,
+ .bg_color = .{
+ options.config.background.r,
+ options.config.background.g,
+ options.config.background.b,
+ @intFromFloat(@round(options.config.background_opacity * 255.0)),
+ },
.cursor_wide = false,
+ .use_display_p3 = options.config.colorspace == .@"display-p3",
+ .use_linear_blending = options.config.blending.isLinear(),
+ .use_linear_correction = options.config.blending == .@"linear-corrected",
},
// Fonts
@@ -682,16 +724,19 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.font_shaper = font_shaper,
.font_shaper_cache = font.ShaperCache.init(),
- // Shaders
- .shaders = shaders,
+ // Shaders (initialized below)
+ .shaders = undefined,
// Metal stuff
.layer = layer,
.display_link = display_link,
- .custom_shader_state = custom_shader_state,
+ .terminal_colorspace = terminal_colorspace,
+ .custom_shader_state = null,
.gpu_state = gpu_state,
};
+ try result.initShaders();
+
// Do an initialize screen size setup to ensure our undefined values
// above are initialized.
try result.setScreenSize(result.size);
@@ -709,6 +754,8 @@ pub fn deinit(self: *Metal) void {
}
}
+ self.terminal_colorspace.release();
+
self.cells.deinit(self.alloc);
self.font_shaper.deinit();
@@ -723,11 +770,82 @@ pub fn deinit(self: *Metal) void {
}
self.image_placements.deinit(self.alloc);
+ self.deinitShaders();
+
+ self.* = undefined;
+}
+
+fn deinitShaders(self: *Metal) void {
if (self.custom_shader_state) |*state| state.deinit();
self.shaders.deinit(self.alloc);
+}
- self.* = undefined;
+fn initShaders(self: *Metal) !void {
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const arena_alloc = arena.allocator();
+
+ // Load our custom shaders
+ const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
+ arena_alloc,
+ self.config.custom_shaders,
+ .msl,
+ ) catch |err| err: {
+ log.warn("error loading custom shaders err={}", .{err});
+ break :err &.{};
+ };
+
+ var custom_shader_state: ?CustomShaderState = state: {
+ if (custom_shaders.len == 0) break :state null;
+
+ // Build our sampler for our texture
+ var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device);
+ errdefer sampler.deinit();
+
+ break :state .{
+ // Resolution and screen textures will be fixed up by first
+ // call to setScreenSize. Draw calls will bail out early if
+ // the screen size hasn't been set yet, so it won't error.
+ .front_texture = undefined,
+ .back_texture = undefined,
+ .sampler = sampler,
+ .uniforms = .{
+ .resolution = .{ 0, 0, 1 },
+ .time = 1,
+ .time_delta = 1,
+ .frame_rate = 1,
+ .frame = 1,
+ .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
+ .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
+ .mouse = .{ 0, 0, 0, 0 },
+ .date = .{ 0, 0, 0, 0 },
+ .sample_rate = 1,
+ },
+
+ .first_frame_time = try std.time.Instant.now(),
+ .last_frame_time = try std.time.Instant.now(),
+ };
+ };
+ errdefer if (custom_shader_state) |*state| state.deinit();
+
+ var shaders = try Shaders.init(
+ self.alloc,
+ self.gpu_state.device,
+ custom_shaders,
+ // Using an `*_srgb` pixel format makes Metal gamma encode
+ // the pixels written to it *after* blending, which means
+ // we get linear alpha blending rather than gamma-incorrect
+ // blending.
+ if (self.config.blending.isLinear())
+ mtl.MTLPixelFormat.bgra8unorm_srgb
+ else
+ mtl.MTLPixelFormat.bgra8unorm,
+ );
+ errdefer shaders.deinit(self.alloc);
+
+ self.shaders = shaders;
+ self.custom_shader_state = custom_shader_state;
}
/// This is called just prior to spinning up the renderer thread for
@@ -749,8 +867,9 @@ pub fn threadEnter(self: *const Metal, surface: *apprt.Surface) !void {
}
/// Callback called by renderer.Thread when it exits.
-pub fn threadExit(self: *const Metal) void {
+pub fn threadExit(self: *const Metal, surface: *apprt.Surface) void {
_ = self;
+ _ = surface;
// Metal requires no per-thread state.
}
@@ -977,19 +1096,6 @@ pub fn updateFrame(
}
}
- // If our terminal screen size doesn't match our expected renderer
- // size then we skip a frame. This can happen if the terminal state
- // is resized between when the renderer mailbox is drained and when
- // the state mutex is acquired inside this function.
- //
- // For some reason this doesn't seem to cause any significant issues
- // with flickering while resizing. '\_('-')_/'
- if (self.cells.size.rows != state.terminal.rows or
- self.cells.size.columns != state.terminal.cols)
- {
- return;
- }
-
// Get the viewport pin so that we can compare it to the current.
const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
@@ -1111,7 +1217,38 @@ pub fn updateFrame(
self.cells_viewport = critical.viewport_pin;
// Update our background color
- self.current_background_color = critical.bg;
+ self.uniforms.bg_color = .{
+ critical.bg.r,
+ critical.bg.g,
+ critical.bg.b,
+ @intFromFloat(@round(self.config.background_opacity * 255.0)),
+ };
+
+ // Update the background color on our layer
+ //
+ // TODO: Is this expensive? Should we be checking if our
+ // bg color has changed first before doing this work?
+ {
+ const color = graphics.c.CGColorCreate(
+ @ptrCast(self.terminal_colorspace),
+ &[4]f64{
+ @as(f64, @floatFromInt(critical.bg.r)) / 255.0,
+ @as(f64, @floatFromInt(critical.bg.g)) / 255.0,
+ @as(f64, @floatFromInt(critical.bg.b)) / 255.0,
+ self.config.background_opacity,
+ },
+ );
+ defer graphics.c.CGColorRelease(color);
+
+ // We use a CATransaction so that Core Animation knows that we
+ // updated the background color property. Otherwise it behaves
+ // weird, not updating the color until we resize.
+ const CATransaction = objc.getClass("CATransaction").?;
+ CATransaction.msgSend(void, "begin", .{});
+ defer CATransaction.msgSend(void, "commit", .{});
+
+ self.layer.setProperty("backgroundColor", color);
+ }
// Go through our images and see if we need to setup any textures.
{
@@ -1128,7 +1265,11 @@ pub fn updateFrame(
.replace_gray_alpha,
.replace_rgb,
.replace_rgba,
- => try kv.value_ptr.image.upload(self.alloc, self.gpu_state.device),
+ => try kv.value_ptr.image.upload(
+ self.alloc,
+ self.gpu_state.device,
+ self.gpu_state.default_storage_mode,
+ ),
.unload_pending,
.unload_replace,
@@ -1196,7 +1337,12 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
self.font_grid.lock.lockShared();
defer self.font_grid.lock.unlockShared();
frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
- try syncAtlasTexture(self.gpu_state.device, &self.font_grid.atlas_grayscale, &frame.grayscale);
+ try syncAtlasTexture(
+ self.gpu_state.device,
+ &self.font_grid.atlas_grayscale,
+ &frame.grayscale,
+ self.gpu_state.default_storage_mode,
+ );
}
texture: {
const modified = self.font_grid.atlas_color.modified.load(.monotonic);
@@ -1204,7 +1350,12 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
self.font_grid.lock.lockShared();
defer self.font_grid.lock.unlockShared();
frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic);
- try syncAtlasTexture(self.gpu_state.device, &self.font_grid.atlas_color, &frame.color);
+ try syncAtlasTexture(
+ self.gpu_state.device,
+ &self.font_grid.atlas_color,
+ &frame.color,
+ self.gpu_state.default_storage_mode,
+ );
}
// Command buffer (MTLCommandBuffer)
@@ -1233,10 +1384,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store));
attachment.setProperty("texture", screen_texture.value);
attachment.setProperty("clearColor", mtl.MTLClearColor{
- .red = @as(f32, @floatFromInt(self.current_background_color.r)) / 255 * self.config.background_opacity,
- .green = @as(f32, @floatFromInt(self.current_background_color.g)) / 255 * self.config.background_opacity,
- .blue = @as(f32, @floatFromInt(self.current_background_color.b)) / 255 * self.config.background_opacity,
- .alpha = self.config.background_opacity,
+ .red = 0.0,
+ .green = 0.0,
+ .blue = 0.0,
+ .alpha = 0.0,
});
}
@@ -1252,19 +1403,19 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
// Draw background images first
- try self.drawImagePlacements(encoder, self.image_placements.items[0..self.image_bg_end]);
+ try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]);
// Then draw background cells
try self.drawCellBgs(encoder, frame);
// Then draw images under text
- try self.drawImagePlacements(encoder, self.image_placements.items[self.image_bg_end..self.image_text_end]);
+ try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]);
// Then draw fg cells
try self.drawCellFgs(encoder, frame, fg_count);
// Then draw remaining images
- try self.drawImagePlacements(encoder, self.image_placements.items[self.image_text_end..]);
+ try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]);
}
// If we have custom shaders, then we render them.
@@ -1457,6 +1608,7 @@ fn drawPostShader(
fn drawImagePlacements(
self: *Metal,
encoder: objc.Object,
+ frame: *const FrameState,
placements: []const mtl_image.Placement,
) !void {
if (placements.len == 0) return;
@@ -1468,15 +1620,16 @@ fn drawImagePlacements(
.{self.shaders.image_pipeline.value},
);
- // Set our uniform, which is the only shared buffer
+ // Set our uniforms
encoder.msgSend(
void,
- objc.sel("setVertexBytes:length:atIndex:"),
- .{
- @as(*const anyopaque, @ptrCast(&self.uniforms)),
- @as(c_ulong, @sizeOf(@TypeOf(self.uniforms))),
- @as(c_ulong, 1),
- },
+ objc.sel("setVertexBuffer:offset:atIndex:"),
+ .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
+ );
+ encoder.msgSend(
+ void,
+ objc.sel("setFragmentBuffer:offset:atIndex:"),
+ .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
);
for (placements) |placement| {
@@ -1529,7 +1682,11 @@ fn drawImagePlacement(
@as(f32, @floatFromInt(p.width)),
@as(f32, @floatFromInt(p.height)),
},
- }});
+ }}, .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = self.gpu_state.default_storage_mode,
+ });
defer buf.deinit();
// Set our buffer
@@ -1588,6 +1745,11 @@ fn drawCellBgs(
);
// Set our buffers
+ encoder.msgSend(
+ void,
+ objc.sel("setVertexBuffer:offset:atIndex:"),
+ .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
+ );
encoder.msgSend(
void,
objc.sel("setFragmentBuffer:offset:atIndex:"),
@@ -1647,18 +1809,17 @@ fn drawCellFgs(
encoder.msgSend(
void,
objc.sel("setFragmentTexture:atIndex:"),
- .{
- frame.grayscale.value,
- @as(c_ulong, 0),
- },
+ .{ frame.grayscale.value, @as(c_ulong, 0) },
);
encoder.msgSend(
void,
objc.sel("setFragmentTexture:atIndex:"),
- .{
- frame.color.value,
- @as(c_ulong, 1),
- },
+ .{ frame.color.value, @as(c_ulong, 1) },
+ );
+ encoder.msgSend(
+ void,
+ objc.sel("setFragmentBuffer:offset:atIndex:"),
+ .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) },
);
encoder.msgSend(
@@ -2003,17 +2164,73 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void {
// Set our new minimum contrast
self.uniforms.min_contrast = config.min_contrast;
+ // Set our new color space and blending
+ self.uniforms.use_display_p3 = config.colorspace == .@"display-p3";
+ self.uniforms.use_linear_blending = config.blending.isLinear();
+ self.uniforms.use_linear_correction = config.blending == .@"linear-corrected";
+
// Set our new colors
self.default_background_color = config.background;
self.default_foreground_color = config.foreground;
self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
self.cursor_invert = config.cursor_invert;
+ // Update our layer's opaqueness and display sync in case they changed.
+ {
+ // We use a CATransaction so that Core Animation knows that we
+ // updated the opaque property. Otherwise it behaves weird, not
+ // properly going from opaque to transparent unless we resize.
+ const CATransaction = objc.getClass("CATransaction").?;
+ CATransaction.msgSend(void, "begin", .{});
+ defer CATransaction.msgSend(void, "commit", .{});
+
+ self.layer.setProperty("opaque", config.background_opacity >= 1);
+ self.layer.setProperty("displaySyncEnabled", config.vsync);
+ }
+
+ // Update our terminal colorspace if it changed
+ if (self.config.colorspace != config.colorspace) {
+ const terminal_colorspace = try graphics.ColorSpace.createNamed(
+ switch (config.colorspace) {
+ .@"display-p3" => .displayP3,
+ .srgb => .sRGB,
+ },
+ );
+ errdefer terminal_colorspace.release();
+ self.terminal_colorspace.release();
+ self.terminal_colorspace = terminal_colorspace;
+ }
+
+ const old_blending = self.config.blending;
+ const old_custom_shaders = self.config.custom_shaders;
+
self.config.deinit();
self.config = config.*;
// Reset our viewport to force a rebuild, in case of a font change.
self.cells_viewport = null;
+
+ // We reinitialize our shaders if our
+ // blending or custom shaders changed.
+ if (old_blending != config.blending or
+ !old_custom_shaders.equal(config.custom_shaders))
+ {
+ self.deinitShaders();
+ try self.initShaders();
+ // We call setScreenSize to reinitialize
+ // the textures used for custom shaders.
+ if (self.custom_shader_state != null) {
+ try self.setScreenSize(self.size);
+ }
+ // And we update our layer's pixel format appropriately.
+ self.layer.setProperty(
+ "pixelFormat",
+ if (config.blending.isLinear())
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
+ else
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
+ );
+ }
}
/// Resize the screen.
@@ -2057,7 +2274,7 @@ pub fn setScreenSize(
}
// Set the size of the drawable surface to the bounds
- self.layer.setProperty("drawableSize", macos.graphics.Size{
+ self.layer.setProperty("drawableSize", graphics.Size{
.width = @floatFromInt(size.screen.width),
.height = @floatFromInt(size.screen.height),
});
@@ -2089,7 +2306,11 @@ pub fn setScreenSize(
.min_contrast = old.min_contrast,
.cursor_pos = old.cursor_pos,
.cursor_color = old.cursor_color,
+ .bg_color = old.bg_color,
.cursor_wide = old.cursor_wide,
+ .use_display_p3 = old.use_display_p3,
+ .use_linear_blending = old.use_linear_blending,
+ .use_linear_correction = old.use_linear_correction,
};
// Reset our cell contents if our grid size has changed.
@@ -2124,7 +2345,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
- desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
+ desc.setProperty(
+ "pixelFormat",
+ // Using an `*_srgb` pixel format makes Metal gamma encode
+ // the pixels written to it *after* blending, which means
+ // we get linear alpha blending rather than gamma-incorrect
+ // blending.
+ if (self.config.blending.isLinear())
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
+ else
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
+ );
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty(
@@ -2154,7 +2385,17 @@ pub fn setScreenSize(
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
break :init id_init;
};
- desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm));
+ desc.setProperty(
+ "pixelFormat",
+ // Using an `*_srgb` pixel format makes Metal gamma encode
+ // the pixels written to it *after* blending, which means
+ // we get linear alpha blending rather than gamma-incorrect
+ // blending.
+ if (self.config.blending.isLinear())
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
+ else
+ @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
+ );
desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
desc.setProperty(
@@ -2251,12 +2492,22 @@ fn rebuildCells(
}
}
- // Go row-by-row to build the cells. We go row by row because we do
- // font shaping by row. In the future, we will also do dirty tracking
- // by row.
+ // We rebuild the cells row-by-row because we
+ // do font shaping and dirty tracking by row.
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
- var y: terminal.size.CellCountInt = screen.pages.rows;
+ // If our cell contents buffer is shorter than the screen viewport,
+ // we render the rows that fit, starting from the bottom. If instead
+ // the viewport is shorter than the cell contents buffer, we align
+ // the top of the viewport with the top of the contents buffer.
+ var y: terminal.size.CellCountInt = @min(
+ screen.pages.rows,
+ self.cells.size.rows,
+ );
while (row_it.next()) |row| {
+ // The viewport may have more rows than our cell contents,
+ // so we need to break from the loop early if we hit y = 0.
+ if (y == 0) break;
+
y -= 1;
if (!rebuild) {
@@ -2315,7 +2566,11 @@ fn rebuildCells(
var shaper_cells: ?[]const font.shape.Cell = null;
var shaper_cells_i: usize = 0;
- const row_cells = row.cells(.all);
+ const row_cells_all = row.cells(.all);
+
+ // If our viewport is wider than our cell contents buffer,
+ // we still only process cells up to the width of the buffer.
+ const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)];
for (row_cells, 0..) |*cell, x| {
// If this cell falls within our preedit range then we
@@ -2466,8 +2721,10 @@ fn rebuildCells(
// Foreground alpha for this cell.
const alpha: u8 = if (style.flags.faint) 175 else 255;
- // If the cell has a background color, set it.
- if (bg) |rgb| {
+ // Set the cell's background color.
+ {
+ const rgb = bg orelse self.background_color orelse self.default_background_color;
+
// Determine our background alpha. If we have transparency configured
// then this is dynamic depending on some situations. This is all
// in an attempt to make transparency look the best for various
@@ -2477,23 +2734,19 @@ fn rebuildCells(
if (self.config.background_opacity >= 1) break :bg_alpha default;
- // If we're selected, we do not apply background opacity
+ // Cells that are selected should be fully opaque.
if (selected) break :bg_alpha default;
- // If we're reversed, do not apply background opacity
+ // Cells that are reversed should be fully opaque.
if (style.flags.inverse) break :bg_alpha default;
- // If we have a background and its not the default background
- // then we apply background opacity
- if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) {
+ // Cells that have an explicit bg color should be fully opaque.
+ if (bg_style != null) {
break :bg_alpha default;
}
- // We apply background opacity.
- var bg_alpha: f64 = @floatFromInt(default);
- bg_alpha *= self.config.background_opacity;
- bg_alpha = @ceil(bg_alpha);
- break :bg_alpha @intFromFloat(bg_alpha);
+ // Otherwise, we use the configured background opacity.
+ break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0));
};
self.cells.bgCell(y, x).* = .{
@@ -3032,14 +3285,20 @@ fn addPreeditCell(
/// Sync the atlas data to the given texture. This copies the bytes
/// associated with the atlas to the given texture. If the atlas no longer
/// fits into the texture, the texture will be resized.
-fn syncAtlasTexture(device: objc.Object, atlas: *const font.Atlas, texture: *objc.Object) !void {
+fn syncAtlasTexture(
+ device: objc.Object,
+ atlas: *const font.Atlas,
+ texture: *objc.Object,
+ /// Storage mode for the MTLTexture object
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
+) !void {
const width = texture.getProperty(c_ulong, "width");
if (atlas.size > width) {
// Free our old texture
texture.*.release();
// Reallocate
- texture.* = try initAtlasTexture(device, atlas);
+ texture.* = try initAtlasTexture(device, atlas, storage_mode);
}
texture.msgSend(
@@ -3062,7 +3321,12 @@ fn syncAtlasTexture(device: objc.Object, atlas: *const font.Atlas, texture: *obj
}
/// Initialize a MTLTexture object for the given atlas.
-fn initAtlasTexture(device: objc.Object, atlas: *const font.Atlas) !objc.Object {
+fn initAtlasTexture(
+ device: objc.Object,
+ atlas: *const font.Atlas,
+ /// Storage mode for the MTLTexture object
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
+) !objc.Object {
// Determine our pixel format
const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) {
.grayscale => .r8unorm,
@@ -3083,15 +3347,14 @@ fn initAtlasTexture(device: objc.Object, atlas: *const font.Atlas) !objc.Object
desc.setProperty("width", @as(c_ulong, @intCast(atlas.size)));
desc.setProperty("height", @as(c_ulong, @intCast(atlas.size)));
- // Xcode tells us that this texture should be shared mode on
- // aarch64. This configuration is not supported on x86_64 so
- // we only set it on aarch64.
- if (comptime builtin.target.cpu.arch == .aarch64) {
- desc.setProperty(
- "storageMode",
- @as(c_ulong, mtl.MTLResourceStorageModeShared),
- );
- }
+ desc.setProperty(
+ "resourceOptions",
+ mtl.MTLResourceOptions{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = storage_mode,
+ },
+ );
// Initialize
const id = device.msgSend(
diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig
index e5dec6b2bf..ecf12900b9 100644
--- a/src/renderer/OpenGL.zig
+++ b/src/renderer/OpenGL.zig
@@ -453,7 +453,7 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
const self: OpenGL = undefined;
switch (apprt.runtime) {
- else => @compileError("unsupported app runtime for OpenGL"),
+ else => try self.threadEnter(surface),
apprt.gtk => {
// GTK uses global OpenGL context so we load from null.
@@ -493,12 +493,13 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
/// final main thread setup requirements.
pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void {
_ = self;
- _ = surface;
// For GLFW, we grabbed the OpenGL context in surfaceInit and we
// need to release it before we start the renderer thread.
if (apprt.runtime == apprt.glfw) {
glfw.makeContextCurrent(null);
+ } else if (apprt.runtime == apprt.gtk) {} else {
+ try surface.finalizeSurfaceInit();
}
}
@@ -557,7 +558,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void {
_ = self;
switch (apprt.runtime) {
- else => @compileError("unsupported app runtime for OpenGL"),
+ else => try surface.threadEnter(),
apprt.gtk => {
// GTK doesn't support threaded OpenGL operations as far as I can
@@ -595,11 +596,11 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void {
}
/// Callback called by renderer.Thread when it exits.
-pub fn threadExit(self: *const OpenGL) void {
+pub fn threadExit(self: *const OpenGL, surface: *apprt.Surface) void {
_ = self;
switch (apprt.runtime) {
- else => @compileError("unsupported app runtime for OpenGL"),
+ else => surface.threadExit(),
apprt.gtk => {
// We don't need to do any unloading for GTK because we may
@@ -706,8 +707,6 @@ pub fn updateFrame(
// Update all our data as tightly as possible within the mutex.
var critical: Critical = critical: {
- const grid_size = self.size.grid();
-
state.mutex.lock();
defer state.mutex.unlock();
@@ -748,19 +747,6 @@ pub fn updateFrame(
}
}
- // If our terminal screen size doesn't match our expected renderer
- // size then we skip a frame. This can happen if the terminal state
- // is resized between when the renderer mailbox is drained and when
- // the state mutex is acquired inside this function.
- //
- // For some reason this doesn't seem to cause any significant issues
- // with flickering while resizing. '\_('-')_/'
- if (grid_size.rows != state.terminal.rows or
- grid_size.columns != state.terminal.cols)
- {
- return;
- }
-
// Get the viewport pin so that we can compare it to the current.
const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
@@ -1276,10 +1262,23 @@ pub fn rebuildCells(
}
}
- // Build each cell
+ const grid_size = self.size.grid();
+
+ // We rebuild the cells row-by-row because we do font shaping by row.
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
- var y: terminal.size.CellCountInt = screen.pages.rows;
+ // If our cell contents buffer is shorter than the screen viewport,
+ // we render the rows that fit, starting from the bottom. If instead
+ // the viewport is shorter than the cell contents buffer, we align
+ // the top of the viewport with the top of the contents buffer.
+ var y: terminal.size.CellCountInt = @min(
+ screen.pages.rows,
+ grid_size.rows,
+ );
while (row_it.next()) |row| {
+ // The viewport may have more rows than our cell contents,
+ // so we need to break from the loop early if we hit y = 0.
+ if (y == 0) break;
+
y -= 1;
// True if we want to do font shaping around the cursor. We want to
@@ -1356,7 +1355,11 @@ pub fn rebuildCells(
var shaper_cells: ?[]const font.shape.Cell = null;
var shaper_cells_i: usize = 0;
- const row_cells = row.cells(.all);
+ const row_cells_all = row.cells(.all);
+
+ // If our viewport is wider than our cell contents buffer,
+ // we still only process cells up to the width of the buffer.
+ const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)];
for (row_cells, 0..) |*cell, x| {
// If this cell falls within our preedit range then we
@@ -2345,16 +2348,14 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void {
apprt.glfw => surface.window.swapBuffers(),
apprt.gtk => {},
apprt.embedded => {},
- else => @compileError("unsupported runtime"),
+ else => try surface.swapBuffers(),
}
}
/// Draw the custom shaders.
-fn drawCustomPrograms(
- self: *OpenGL,
- custom_state: *custom.State,
-) !void {
+fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void {
_ = self;
+ assert(custom_state.programs.len > 0);
// Bind our state that is global to all custom shaders
const custom_bind = try custom_state.bind();
@@ -2365,10 +2366,10 @@ fn drawCustomPrograms(
// Go through each custom shader and draw it.
for (custom_state.programs) |program| {
- // Bind our cell program state, buffers
const bind = try program.bind();
defer bind.unbind();
try bind.draw();
+ try custom_state.copyFramebuffer();
}
}
diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig
index cc63889fa3..0809406d10 100644
--- a/src/renderer/Thread.zig
+++ b/src/renderer/Thread.zig
@@ -217,7 +217,7 @@ fn threadMain_(self: *Thread) !void {
// renderers have to do per-thread setup. For example, OpenGL has to set
// some thread-local state since that is how it works.
try self.renderer.threadEnter(self.surface);
- defer self.renderer.threadExit();
+ defer self.renderer.threadExit(self.surface);
// Start the async handlers
self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback);
diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig
index 48056ae5ee..535a0b42b0 100644
--- a/src/renderer/metal/api.zig
+++ b/src/renderer/metal/api.zig
@@ -24,12 +24,36 @@ pub const MTLStoreAction = enum(c_ulong) {
store = 1,
};
-/// https://developer.apple.com/documentation/metal/mtlstoragemode?language=objc
-pub const MTLStorageMode = enum(c_ulong) {
- shared = 0,
- managed = 1,
- private = 2,
- memoryless = 3,
+/// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc
+pub const MTLResourceOptions = packed struct(c_ulong) {
+ /// https://developer.apple.com/documentation/metal/mtlcpucachemode?language=objc
+ cpu_cache_mode: CPUCacheMode = .default,
+ /// https://developer.apple.com/documentation/metal/mtlstoragemode?language=objc
+ storage_mode: StorageMode,
+ /// https://developer.apple.com/documentation/metal/mtlhazardtrackingmode?language=objc
+ hazard_tracking_mode: HazardTrackingMode = .default,
+
+ _pad: @Type(.{
+ .Int = .{ .signedness = .unsigned, .bits = @bitSizeOf(c_ulong) - 10 },
+ }) = 0,
+
+ pub const CPUCacheMode = enum(u4) {
+ default = 0,
+ write_combined = 1,
+ };
+
+ pub const StorageMode = enum(u4) {
+ shared = 0,
+ managed = 1,
+ private = 2,
+ memoryless = 3,
+ };
+
+ pub const HazardTrackingMode = enum(u2) {
+ default = 0,
+ untracked = 1,
+ tracked = 2,
+ };
};
/// https://developer.apple.com/documentation/metal/mtlprimitivetype?language=objc
@@ -74,6 +98,7 @@ pub const MTLPixelFormat = enum(c_ulong) {
rgba8unorm = 70,
rgba8uint = 73,
bgra8unorm = 80,
+ bgra8unorm_srgb = 81,
};
/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc
@@ -138,10 +163,6 @@ pub const MTLTextureUsage = enum(c_ulong) {
pixel_format_view = 8,
};
-/// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc
-/// (incomplete, we only use this mode so we just hardcode it)
-pub const MTLResourceStorageModeShared: c_ulong = @intFromEnum(MTLStorageMode.shared) << 4;
-
pub const MTLClearColor = extern struct {
red: f64,
green: f64,
diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig
index 55a207f030..4128e297b7 100644
--- a/src/renderer/metal/buffer.zig
+++ b/src/renderer/metal/buffer.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const objc = @import("objc");
+const macos = @import("macos");
const mtl = @import("api.zig");
@@ -14,35 +15,46 @@ pub fn Buffer(comptime T: type) type {
return struct {
const Self = @This();
+ /// The resource options for this buffer.
+ options: mtl.MTLResourceOptions,
+
buffer: objc.Object, // MTLBuffer
/// Initialize a buffer with the given length pre-allocated.
- pub fn init(device: objc.Object, len: usize) !Self {
+ pub fn init(
+ device: objc.Object,
+ len: usize,
+ options: mtl.MTLResourceOptions,
+ ) !Self {
const buffer = device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(len * @sizeOf(T))),
- mtl.MTLResourceStorageModeShared,
+ options,
},
);
- return .{ .buffer = buffer };
+ return .{ .buffer = buffer, .options = options };
}
/// Init the buffer filled with the given data.
- pub fn initFill(device: objc.Object, data: []const T) !Self {
+ pub fn initFill(
+ device: objc.Object,
+ data: []const T,
+ options: mtl.MTLResourceOptions,
+ ) !Self {
const buffer = device.msgSend(
objc.Object,
objc.sel("newBufferWithBytes:length:options:"),
.{
@as(*const anyopaque, @ptrCast(data.ptr)),
@as(c_ulong, @intCast(data.len * @sizeOf(T))),
- mtl.MTLResourceStorageModeShared,
+ options,
},
);
- return .{ .buffer = buffer };
+ return .{ .buffer = buffer, .options = options };
}
pub fn deinit(self: *Self) void {
@@ -85,7 +97,7 @@ pub fn Buffer(comptime T: type) type {
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(size * @sizeOf(T))),
- mtl.MTLResourceStorageModeShared,
+ self.options,
},
);
}
@@ -106,6 +118,18 @@ pub fn Buffer(comptime T: type) type {
};
@memcpy(dst, src);
+
+ // If we're using the managed resource storage mode, then
+ // we need to signal Metal to synchronize the buffer data.
+ //
+ // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc
+ if (self.options.storage_mode == .managed) {
+ self.buffer.msgSend(
+ void,
+ "didModifyRange:",
+ .{macos.foundation.Range.init(0, req_bytes)},
+ );
+ }
}
/// Like Buffer.sync but takes data from an array of ArrayLists,
@@ -130,7 +154,7 @@ pub fn Buffer(comptime T: type) type {
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(size * @sizeOf(T))),
- mtl.MTLResourceStorageModeShared,
+ self.options,
},
);
}
@@ -153,6 +177,18 @@ pub fn Buffer(comptime T: type) type {
i += list.items.len * @sizeOf(T);
}
+ // If we're using the managed resource storage mode, then
+ // we need to signal Metal to synchronize the buffer data.
+ //
+ // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc
+ if (self.options.storage_mode == .managed) {
+ self.buffer.msgSend(
+ void,
+ "didModifyRange:",
+ .{macos.foundation.Range.init(0, req_bytes)},
+ );
+ }
+
return total_len;
}
};
diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig
index 9d72cae96a..835fbd672b 100644
--- a/src/renderer/metal/image.zig
+++ b/src/renderer/metal/image.zig
@@ -358,6 +358,8 @@ pub const Image = union(enum) {
self: *Image,
alloc: Allocator,
device: objc.Object,
+ /// Storage mode for the MTLTexture object
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
) !void {
// Convert our data if we have to
try self.convert(alloc);
@@ -366,7 +368,7 @@ pub const Image = union(enum) {
const p = self.pending().?;
// Create our texture
- const texture = try initTexture(p, device);
+ const texture = try initTexture(p, device, storage_mode);
errdefer texture.msgSend(void, objc.sel("release"), .{});
// Upload our data
@@ -424,7 +426,12 @@ pub const Image = union(enum) {
};
}
- fn initTexture(p: Pending, device: objc.Object) !objc.Object {
+ fn initTexture(
+ p: Pending,
+ device: objc.Object,
+ /// Storage mode for the MTLTexture object
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
+ ) !objc.Object {
// Create our descriptor
const desc = init: {
const Class = objc.getClass("MTLTextureDescriptor").?;
@@ -438,6 +445,15 @@ pub const Image = union(enum) {
desc.setProperty("width", @as(c_ulong, @intCast(p.width)));
desc.setProperty("height", @as(c_ulong, @intCast(p.height)));
+ desc.setProperty(
+ "resourceOptions",
+ mtl.MTLResourceOptions{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = storage_mode,
+ },
+ );
+
// Initialize
const id = device.msgSend(
?*anyopaque,
diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig
index b909a2f2a9..b297de809c 100644
--- a/src/renderer/metal/shaders.zig
+++ b/src/renderer/metal/shaders.zig
@@ -13,9 +13,7 @@ const log = std.log.scoped(.metal);
pub const Shaders = struct {
library: objc.Object,
- /// The cell shader is the shader used to render the terminal cells.
- /// It is a single shader that is used for both the background and
- /// foreground.
+ /// Renders cell foreground elements (text, decorations).
cell_text_pipeline: objc.Object,
/// The cell background shader is the shader used to render the
@@ -40,17 +38,18 @@ pub const Shaders = struct {
alloc: Allocator,
device: objc.Object,
post_shaders: []const [:0]const u8,
+ pixel_format: mtl.MTLPixelFormat,
) !Shaders {
const library = try initLibrary(device);
errdefer library.msgSend(void, objc.sel("release"), .{});
- const cell_text_pipeline = try initCellTextPipeline(device, library);
+ const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format);
errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{});
- const cell_bg_pipeline = try initCellBgPipeline(device, library);
+ const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format);
errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{});
- const image_pipeline = try initImagePipeline(device, library);
+ const image_pipeline = try initImagePipeline(device, library, pixel_format);
errdefer image_pipeline.msgSend(void, objc.sel("release"), .{});
const post_pipelines: []const objc.Object = initPostPipelines(
@@ -58,6 +57,7 @@ pub const Shaders = struct {
device,
library,
post_shaders,
+ pixel_format,
) catch |err| err: {
// If an error happens while building postprocess shaders we
// want to just not use any postprocess shaders since we don't
@@ -137,9 +137,29 @@ pub const Uniforms = extern struct {
cursor_pos: [2]u16 align(4),
cursor_color: [4]u8 align(4),
- // Whether the cursor is 2 cells wide.
+ /// The background color for the whole surface.
+ bg_color: [4]u8 align(4),
+
+ /// Whether the cursor is 2 cells wide.
cursor_wide: bool align(1),
+ /// Indicates that colors provided to the shader are already in
+ /// the P3 color space, so they don't need to be converted from
+ /// sRGB.
+ use_display_p3: bool align(1),
+
+ /// Indicates that the color attachments for the shaders have
+ /// an `*_srgb` pixel format, which means the shaders need to
+ /// output linear RGB colors rather than gamma encoded colors,
+ /// since blending will be performed in linear space and then
+ /// Metal itself will re-encode the colors for storage.
+ use_linear_blending: bool align(1),
+
+ /// Enables a weight correction step that makes text rendered
+ /// with linear alpha blending have a similar apparent weight
+ /// (thickness) to gamma-incorrect blending.
+ use_linear_correction: bool align(1) = false,
+
const PaddingExtend = packed struct(u8) {
left: bool = false,
right: bool = false,
@@ -201,6 +221,7 @@ fn initPostPipelines(
device: objc.Object,
library: objc.Object,
shaders: []const [:0]const u8,
+ pixel_format: mtl.MTLPixelFormat,
) ![]const objc.Object {
// If we have no shaders, do nothing.
if (shaders.len == 0) return &.{};
@@ -220,7 +241,12 @@ fn initPostPipelines(
// Build each shader. Note we don't use "0.." to build our index
// because we need to keep track of our length to clean up above.
for (shaders) |source| {
- pipelines[i] = try initPostPipeline(device, library, source);
+ pipelines[i] = try initPostPipeline(
+ device,
+ library,
+ source,
+ pixel_format,
+ );
i += 1;
}
@@ -232,6 +258,7 @@ fn initPostPipeline(
device: objc.Object,
library: objc.Object,
data: [:0]const u8,
+ pixel_format: mtl.MTLPixelFormat,
) !objc.Object {
// Create our library which has the shader source
const post_library = library: {
@@ -301,8 +328,7 @@ fn initPostPipeline(
.{@as(c_ulong, 0)},
);
- // Value is MTLPixelFormatBGRA8Unorm
- attachment.setProperty("pixelFormat", @as(c_ulong, 80));
+ attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
}
// Make our state
@@ -343,7 +369,11 @@ pub const CellText = extern struct {
};
/// Initialize the cell render pipeline for our shader library.
-fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object {
+fn initCellTextPipeline(
+ device: objc.Object,
+ library: objc.Object,
+ pixel_format: mtl.MTLPixelFormat,
+) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
@@ -427,8 +457,7 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
.{@as(c_ulong, 0)},
);
- // Value is MTLPixelFormatBGRA8Unorm
- attachment.setProperty("pixelFormat", @as(c_ulong, 80));
+ attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.
@@ -458,11 +487,15 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object
pub const CellBg = [4]u8;
/// Initialize the cell background render pipeline for our shader library.
-fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
+fn initCellBgPipeline(
+ device: objc.Object,
+ library: objc.Object,
+ pixel_format: mtl.MTLPixelFormat,
+) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
- "full_screen_vertex",
+ "cell_bg_vertex",
.utf8,
false,
);
@@ -507,8 +540,7 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)},
);
- // Value is MTLPixelFormatBGRA8Unorm
- attachment.setProperty("pixelFormat", @as(c_ulong, 80));
+ attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.
@@ -535,7 +567,11 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object {
}
/// Initialize the image render pipeline for our shader library.
-fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
+fn initImagePipeline(
+ device: objc.Object,
+ library: objc.Object,
+ pixel_format: mtl.MTLPixelFormat,
+) !objc.Object {
// Get our vertex and fragment functions
const func_vert = func_vert: {
const str = try macos.foundation.String.createWithBytes(
@@ -619,8 +655,7 @@ fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
.{@as(c_ulong, 0)},
);
- // Value is MTLPixelFormatBGRA8Unorm
- attachment.setProperty("pixelFormat", @as(c_ulong, 80));
+ attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
// Blending. This is required so that our text we render on top
// of our drawable properly blends into the bg.
diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig
index 2cab0940c5..859277ce51 100644
--- a/src/renderer/opengl/custom.zig
+++ b/src/renderer/opengl/custom.zig
@@ -230,6 +230,21 @@ pub const State = struct {
};
}
+ /// Copy the fbo's attached texture to the backbuffer.
+ pub fn copyFramebuffer(self: *State) !void {
+ const texbind = try self.fb_texture.bind(.@"2D");
+ errdefer texbind.unbind();
+ try texbind.copySubImage2D(
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ @intFromFloat(self.uniforms.resolution[0]),
+ @intFromFloat(self.uniforms.resolution[1]),
+ );
+ }
+
pub const Binding = struct {
vao: gl.VertexArray.Binding,
ebo: gl.Buffer.Binding,
@@ -251,7 +266,6 @@ pub const Program = struct {
const program = try gl.Program.createVF(
@embedFile("../shaders/custom.v.glsl"),
src,
- //@embedFile("../shaders/temp.f.glsl"),
);
errdefer program.destroy();
diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal
index 2a107402b2..e24ddcb1ef 100644
--- a/src/renderer/shaders/cell.metal
+++ b/src/renderer/shaders/cell.metal
@@ -18,7 +18,11 @@ struct Uniforms {
float min_contrast;
ushort2 cursor_pos;
uchar4 cursor_color;
+ uchar4 bg_color;
bool cursor_wide;
+ bool use_display_p3;
+ bool use_linear_blending;
+ bool use_linear_correction;
};
//-------------------------------------------------------------------
@@ -26,40 +30,88 @@ struct Uniforms {
//-------------------------------------------------------------------
#pragma mark - Colors
-// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
-float luminance_component(float c) {
- if (c <= 0.03928f) {
- return c / 12.92f;
- } else {
- return pow((c + 0.055f) / 1.055f, 2.4f);
- }
+// D50-adapted sRGB to XYZ conversion matrix.
+// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html
+constant float3x3 sRGB_XYZ = transpose(float3x3(
+ 0.4360747, 0.3850649, 0.1430804,
+ 0.2225045, 0.7168786, 0.0606169,
+ 0.0139322, 0.0971045, 0.7141733
+));
+// XYZ to Display P3 conversion matrix.
+// http://endavid.com/index.php?entry=79
+constant float3x3 XYZ_DP3 = transpose(float3x3(
+ 2.40414768,-0.99010704,-0.39759019,
+ -0.84239098, 1.79905954, 0.01597023,
+ 0.04838763,-0.09752546, 1.27393636
+));
+// By composing the two above matrices we get
+// our sRGB to Display P3 conversion matrix.
+constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ;
+
+// Converts a color in linear sRGB to linear Display P3
+//
+// TODO: The color matrix should probably be computed
+// dynamically and passed as a uniform, rather
+// than being hard coded above.
+float3 srgb_to_display_p3(float3 srgb) {
+ return sRGB_DP3 * srgb;
+}
+
+// Converts a color from sRGB gamma encoding to linear.
+float4 linearize(float4 srgb) {
+ bool3 cutoff = srgb.rgb <= 0.04045;
+ float3 lower = srgb.rgb / 12.92;
+ float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4);
+ srgb.rgb = mix(higher, lower, float3(cutoff));
+
+ return srgb;
}
+float linearize(float v) {
+ return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4);
+}
+
+// Converts a color from linear to sRGB gamma encoding.
+float4 unlinearize(float4 linear) {
+ bool3 cutoff = linear.rgb <= 0.0031308;
+ float3 lower = linear.rgb * 12.92;
+ float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055;
+ linear.rgb = mix(higher, lower, float3(cutoff));
-float relative_luminance(float3 color) {
- color.r = luminance_component(color.r);
- color.g = luminance_component(color.g);
- color.b = luminance_component(color.b);
- float3 weights = float3(0.2126f, 0.7152f, 0.0722f);
- return dot(color, weights);
+ return linear;
+}
+float unlinearize(float v) {
+ return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055;
+}
+
+// Compute the luminance of the provided color.
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
+float luminance(float3 color) {
+ return dot(color, float3(0.2126f, 0.7152f, 0.0722f));
}
// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
float contrast_ratio(float3 color1, float3 color2) {
- float l1 = relative_luminance(color1);
- float l2 = relative_luminance(color2);
+ float l1 = luminance(color1);
+ float l2 = luminance(color2);
return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f);
}
// Return the fg if the contrast ratio is greater than min, otherwise
// return a color that satisfies the contrast ratio. Currently, the color
// is always white or black, whichever has the highest contrast ratio.
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
float4 contrasted_color(float min, float4 fg, float4 bg) {
- float3 fg_premult = fg.rgb * fg.a;
- float3 bg_premult = bg.rgb * bg.a;
- float ratio = contrast_ratio(fg_premult, bg_premult);
+ float ratio = contrast_ratio(fg.rgb, bg.rgb);
if (ratio < min) {
- float white_ratio = contrast_ratio(float3(1.0f), bg_premult);
- float black_ratio = contrast_ratio(float3(0.0f), bg_premult);
+ float white_ratio = contrast_ratio(float3(1.0f), bg.rgb);
+ float black_ratio = contrast_ratio(float3(0.0f), bg.rgb);
if (white_ratio > black_ratio) {
return float4(1.0f);
} else {
@@ -70,6 +122,62 @@ float4 contrasted_color(float min, float4 fg, float4 bg) {
return fg;
}
+// Load a 4 byte RGBA non-premultiplied color and linearize
+// and convert it as necessary depending on the provided info.
+//
+// Returns a color in the Display P3 color space.
+//
+// If `display_p3` is true, then the provided color is assumed to
+// already be in the Display P3 color space, otherwise it's treated
+// as an sRGB color and is appropriately converted to Display P3.
+//
+// `linear` controls whether the returned color is linear or gamma encoded.
+float4 load_color(
+ uchar4 in_color,
+ bool display_p3,
+ bool linear
+) {
+ // 0 .. 255 -> 0.0 .. 1.0
+ float4 color = float4(in_color) / 255.0f;
+
+ // If our color is already in Display P3 and
+ // we aren't doing linear blending, then we
+ // already have the correct color here and
+ // can premultiply and return it.
+ if (display_p3 && !linear) {
+ color.rgb *= color.a;
+ return color;
+ }
+
+ // The color is in either the sRGB or Display P3 color space,
+ // so in either case, it's a color space which uses the sRGB
+ // transfer function, so we can use one function in order to
+ // linearize it in either case.
+ //
+ // Even if we aren't doing linear blending, the color
+ // needs to be in linear space to convert color spaces.
+ color = linearize(color);
+
+ // If we're *NOT* using display P3 colors, then we're dealing
+ // with an sRGB color, in which case we need to convert it in
+ // to the Display P3 color space, since our output is always
+ // Display P3.
+ if (!display_p3) {
+ color.rgb = srgb_to_display_p3(color.rgb);
+ }
+
+ // If we're not doing linear blending, then we need to
+ // unlinearize after doing the color space conversion.
+ if (!linear) {
+ color = unlinearize(color);
+ }
+
+ // Premultiply our color by its alpha.
+ color.rgb *= color.a;
+
+ return color;
+}
+
//-------------------------------------------------------------------
// Full Screen Vertex Shader
//-------------------------------------------------------------------
@@ -112,25 +220,62 @@ vertex FullScreenVertexOut full_screen_vertex(
//-------------------------------------------------------------------
#pragma mark - Cell BG Shader
+struct CellBgVertexOut {
+ float4 position [[position]];
+ float4 bg_color;
+};
+
+vertex CellBgVertexOut cell_bg_vertex(
+ uint vid [[vertex_id]],
+ constant Uniforms& uniforms [[buffer(1)]]
+) {
+ CellBgVertexOut out;
+
+ float4 position;
+ position.x = (vid == 2) ? 3.0 : -1.0;
+ position.y = (vid == 0) ? -3.0 : 1.0;
+ position.zw = 1.0;
+ out.position = position;
+
+ // Convert the background color to Display P3
+ out.bg_color = load_color(
+ uniforms.bg_color,
+ uniforms.use_display_p3,
+ uniforms.use_linear_blending
+ );
+
+ return out;
+}
+
fragment float4 cell_bg_fragment(
- FullScreenVertexOut in [[stage_in]],
+ CellBgVertexOut in [[stage_in]],
constant uchar4 *cells [[buffer(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) {
int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size));
+ float4 bg = float4(0.0);
+ // If we have any background transparency then we render bg-colored cells as
+ // fully transparent, since the background is handled by the layer bg color
+ // and we don't want to double up our bg color, but if our bg color is fully
+ // opaque then our layer is opaque and can't handle transparency, so we need
+ // to return the bg color directly instead.
+ if (uniforms.bg_color.a == 255) {
+ bg = in.bg_color;
+ }
+
// Clamp x position, extends edge bg colors in to padding on sides.
if (grid_pos.x < 0) {
if (uniforms.padding_extend & EXTEND_LEFT) {
grid_pos.x = 0;
} else {
- return float4(0.0);
+ return bg;
}
} else if (grid_pos.x > uniforms.grid_size.x - 1) {
if (uniforms.padding_extend & EXTEND_RIGHT) {
grid_pos.x = uniforms.grid_size.x - 1;
} else {
- return float4(0.0);
+ return bg;
}
}
@@ -139,18 +284,40 @@ fragment float4 cell_bg_fragment(
if (uniforms.padding_extend & EXTEND_UP) {
grid_pos.y = 0;
} else {
- return float4(0.0);
+ return bg;
}
} else if (grid_pos.y > uniforms.grid_size.y - 1) {
if (uniforms.padding_extend & EXTEND_DOWN) {
grid_pos.y = uniforms.grid_size.y - 1;
} else {
- return float4(0.0);
+ return bg;
}
}
- // Retrieve color for cell and return it.
- return float4(cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]) / 255.0;
+ // Load the color for the cell.
+ uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x];
+
+ // We have special case handling for when the cell color matches the bg color.
+ if (all(cell_color == uniforms.bg_color)) {
+ return bg;
+ }
+
+ // Convert the color and return it.
+ //
+ // TODO: We may want to blend the color with the background
+ // color, rather than purely replacing it, this needs
+ // some consideration about config options though.
+ //
+ // TODO: It might be a good idea to do a pass before this
+ // to convert all of the bg colors, so we don't waste
+ // a bunch of work converting the cell color in every
+ // fragment of each cell. It's not the most epxensive
+ // operation, but it is still wasted work.
+ return load_color(
+ cell_color,
+ uniforms.use_display_p3,
+ uniforms.use_linear_blending
+ );
}
//-------------------------------------------------------------------
@@ -192,8 +359,9 @@ struct CellTextVertexIn {
struct CellTextVertexOut {
float4 position [[position]];
- uint8_t mode;
- float4 color;
+ uint8_t mode [[flat]];
+ float4 color [[flat]];
+ float4 bg_color [[flat]];
float2 tex_coord;
};
@@ -222,7 +390,6 @@ vertex CellTextVertexOut cell_text_vertex(
CellTextVertexOut out;
out.mode = in.mode;
- out.color = float4(in.color) / 255.0f;
// === Grid Cell ===
// +X
@@ -277,6 +444,21 @@ vertex CellTextVertexOut cell_text_vertex(
// be sampled with pixel coordinate mode.
out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner;
+ // Get our color. We always fetch a linearized version to
+ // make it easier to handle minimum contrast calculations.
+ out.color = load_color(
+ in.color,
+ uniforms.use_display_p3,
+ true
+ );
+
+ // Get the BG color
+ out.bg_color = load_color(
+ bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x],
+ uniforms.use_display_p3,
+ true
+ );
+
// If we have a minimum contrast, we need to check if we need to
// change the color of the text to ensure it has enough contrast
// with the background.
@@ -285,21 +467,24 @@ vertex CellTextVertexOut cell_text_vertex(
// and Powerline glyphs to be unaffected (else parts of the line would
// have different colors as some parts are displayed via background colors).
if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) {
- float4 bg_color = float4(bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x]) / 255.0f;
- out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color);
+ // Ensure our minimum contrast
+ out.color = contrasted_color(uniforms.min_contrast, out.color, out.bg_color);
}
- // If this cell is the cursor cell, then we need to change the color.
- if (
- in.mode != MODE_TEXT_CURSOR &&
- (
+ // Check if current position is under cursor (including wide cursor)
+ bool is_cursor_pos = (
in.grid_pos.x == uniforms.cursor_pos.x ||
uniforms.cursor_wide &&
in.grid_pos.x == uniforms.cursor_pos.x + 1
- ) &&
- in.grid_pos.y == uniforms.cursor_pos.y
- ) {
- out.color = float4(uniforms.cursor_color) / 255.0f;
+ ) && in.grid_pos.y == uniforms.cursor_pos.y;
+
+ // If this cell is the cursor cell, then we need to change the color.
+ if (in.mode != MODE_TEXT_CURSOR && is_cursor_pos) {
+ out.color = load_color(
+ uniforms.cursor_color,
+ uniforms.use_display_p3,
+ false
+ );
}
return out;
@@ -308,7 +493,8 @@ vertex CellTextVertexOut cell_text_vertex(
fragment float4 cell_text_fragment(
CellTextVertexOut in [[stage_in]],
texture2d textureGrayscale [[texture(0)]],
- texture2d textureColor [[texture(1)]]
+ texture2d textureColor [[texture(1)]],
+ constant Uniforms& uniforms [[buffer(2)]]
) {
constexpr sampler textureSampler(
coord::pixel,
@@ -322,20 +508,72 @@ fragment float4 cell_text_fragment(
case MODE_TEXT_CONSTRAINED:
case MODE_TEXT_POWERLINE:
case MODE_TEXT: {
- // We premult the alpha to our whole color since our blend function
- // uses One/OneMinusSourceAlpha to avoid blurry edges.
- // We first premult our given color.
- float4 premult = float4(in.color.rgb * in.color.a, in.color.a);
-
- // Then premult the texture color
+ // Our input color is always linear.
+ float4 color = in.color;
+
+ // If we're not doing linear blending, then we need to
+ // re-apply the gamma encoding to our color manually.
+ //
+ // Since the alpha is premultiplied, we need to divide
+ // it out before unlinearizing and re-multiply it after.
+ if (!uniforms.use_linear_blending) {
+ color.rgb /= color.a;
+ color = unlinearize(color);
+ color.rgb *= color.a;
+ }
+
+ // Fetch our alpha mask for this pixel.
float a = textureGrayscale.sample(textureSampler, in.tex_coord).r;
- premult = premult * a;
- return premult;
+ // Linear blending weight correction corrects the alpha value to
+ // produce blending results which match gamma-incorrect blending.
+ if (uniforms.use_linear_correction) {
+ // Short explanation of how this works:
+ //
+ // We get the luminances of the foreground and background colors,
+ // and then unlinearize them and perform blending on them. This
+ // gives us our desired luminance, which we derive our new alpha
+ // value from by mapping the range [bg_l, fg_l] to [0, 1], since
+ // our final blend will be a linear interpolation from bg to fg.
+ //
+ // This yields virtually identical results for grayscale blending,
+ // and very similar but non-identical results for color blending.
+ float4 bg = in.bg_color;
+ float fg_l = luminance(color.rgb);
+ float bg_l = luminance(bg.rgb);
+ // To avoid numbers going haywire, we don't apply correction
+ // when the bg and fg luminances are within 0.001 of each other.
+ if (abs(fg_l - bg_l) > 0.001) {
+ float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a));
+ a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0);
+ }
+ }
+
+ // Multiply our whole color by the alpha mask.
+ // Since we use premultiplied alpha, this is
+ // the correct way to apply the mask.
+ color *= a;
+
+ return color;
}
case MODE_TEXT_COLOR: {
- return textureColor.sample(textureSampler, in.tex_coord);
+ // For now, we assume that color glyphs are
+ // already premultiplied Display P3 colors.
+ float4 color = textureColor.sample(textureSampler, in.tex_coord);
+
+ // If we aren't doing linear blending, we can return this right away.
+ if (!uniforms.use_linear_blending) {
+ return color;
+ }
+
+ // Otherwise we need to linearize the color. Since the alpha is
+ // premultiplied, we need to divide it out before linearizing.
+ color.rgb /= color.a;
+ color = linearize(color);
+ color.rgb *= color.a;
+
+ return color;
}
}
}
@@ -409,7 +647,8 @@ vertex ImageVertexOut image_vertex(
fragment float4 image_fragment(
ImageVertexOut in [[stage_in]],
- texture2d image [[texture(0)]]
+ texture2d image [[texture(0)]],
+ constant Uniforms& uniforms [[buffer(1)]]
) {
constexpr sampler textureSampler(address::clamp_to_edge, filter::linear);
@@ -418,10 +657,12 @@ fragment float4 image_fragment(
// our texture to BGRA8Unorm.
uint4 rgba = image.sample(textureSampler, in.tex_coord);
- // Convert to float4 and premultiply the alpha. We should also probably
- // premultiply the alpha in the texture.
- float4 result = float4(rgba) / 255.0f;
- result.rgb *= result.a;
- return result;
+ return load_color(
+ uchar4(rgba),
+ // We assume all images are sRGB regardless of the configured colorspace
+ // TODO: Maybe support wide gamut images?
+ false,
+ uniforms.use_linear_blending
+ );
}
diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md
index 976cf49240..3d5159c711 100644
--- a/src/shell-integration/README.md
+++ b/src/shell-integration/README.md
@@ -6,7 +6,7 @@ supports.
This README is meant as developer documentation and not as
user documentation. For user documentation, see the main
-README.
+README or [ghostty.org](https://ghostty.org/docs)
## Implementation Details
diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh
index 14a677888d..e07da0d1e5 100644
--- a/src/shell-integration/bash/bash-preexec.sh
+++ b/src/shell-integration/bash/bash-preexec.sh
@@ -250,10 +250,8 @@ __bp_preexec_invoke_exec() {
fi
local this_command
- this_command=$(
- export LC_ALL=C
- HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
- )
+ this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
+ this_command="${this_command#*[[:digit:]][* ] }"
# Sanity check to make sure we have something to invoke our function with.
if [[ -z "$this_command" ]]; then
@@ -297,10 +295,8 @@ __bp_install() {
trap '__bp_preexec_invoke_exec "$_"' DEBUG
# Preserve any prior DEBUG trap as a preexec function
- local prior_trap
- # we can't easily do this with variable expansion. Leaving as sed command.
- # shellcheck disable=SC2001
- prior_trap=$(sed "s/[^']*'\(.*\)'[^']*/\1/" <<<"${__bp_trap_string:-}")
+ eval "local trap_argv=(${__bp_trap_string:-})"
+ local prior_trap=${trap_argv[2]:-}
unset __bp_trap_string
if [[ -n "$prior_trap" ]]; then
eval '__bp_original_debug_trap() {
diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash
index 71c644b696..7fae435a3a 100644
--- a/src/shell-integration/bash/ghostty.bash
+++ b/src/shell-integration/bash/ghostty.bash
@@ -20,14 +20,13 @@
if [[ "$-" != *i* ]] ; then builtin return; fi
if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi
-# When automatic shell integration is active, we need to manually
-# load the normal bash startup files based on the injected state.
+# When automatic shell integration is active, we were started in POSIX
+# mode and need to manually recreate the bash startup sequence.
if [ -n "$GHOSTTY_BASH_INJECT" ]; then
- builtin declare ghostty_bash_inject="$GHOSTTY_BASH_INJECT"
- builtin unset GHOSTTY_BASH_INJECT ENV
-
- # At this point, we're in POSIX mode and rely on the injected
- # flags to guide is through the rest of the startup sequence.
+ # Store a temporary copy of our startup flags and unset these global
+ # environment variables so we can safely handle reentrancy.
+ builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT"
+ builtin unset ENV GHOSTTY_BASH_INJECT
# Restore bash's default 'posix' behavior. Also reset 'inherit_errexit',
# which doesn't happen as part of the 'posix' reset.
@@ -40,35 +39,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE
fi
- # Manually source the startup files, respecting the injected flags like
- # --norc and --noprofile that we parsed with the shell integration code.
- #
- # See also: run_startup_files() in shell.c in the Bash source code
+ # Manually source the startup files. See INVOCATION in bash(1) and
+ # run_startup_files() in shell.c in the Bash source code.
if builtin shopt -q login_shell; then
- if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then
+ if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then
[ -r /etc/profile ] && builtin source "/etc/profile"
- for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
- [ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
+ for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
+ [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
done
fi
else
- if [[ $ghostty_bash_inject != *"--norc"* ]]; then
+ if [[ $__ghostty_bash_flags != *"--norc"* ]]; then
# The location of the system bashrc is determined at bash build
# time via -DSYS_BASHRC and can therefore vary across distros:
# Arch, Debian, Ubuntu use /etc/bash.bashrc
# Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC
# Void Linux uses /etc/bash/bashrc
# Nixos uses /etc/bashrc
- for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do
- [ -r "$rcfile" ] && { builtin source "$rcfile"; break; }
+ for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do
+ [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; }
done
if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi
[ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE"
fi
fi
+ builtin unset __ghostty_rcfile
+ builtin unset __ghostty_bash_flags
builtin unset GHOSTTY_BASH_RCFILE
- builtin unset ghostty_bash_inject rcfile
fi
# Sudo
diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish
index 420a495286..cd4f56105b 100644
--- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish
+++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish
@@ -71,11 +71,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
function sudo -d "Wrap sudo to preserve terminfo"
- set --local sudo_has_sudoedit_flags "no"
+ set --function sudo_has_sudoedit_flags "no"
for arg in $argv
# Check if argument is '-e' or '--edit' (sudoedit flags)
if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg"
- set --local sudo_has_sudoedit_flags "yes"
+ set --function sudo_has_sudoedit_flags "yes"
break
end
# Check if argument is neither an option nor a key-value pair
diff --git a/src/stb/stb_image.h b/src/stb/stb_image.h
index 5e807a0a6e..3ae1815c16 100644
--- a/src/stb/stb_image.h
+++ b/src/stb/stb_image.h
@@ -4962,7 +4962,7 @@ static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int
p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0);
if (p == NULL) return stbi__err("outofmem", "Out of memory");
- // between here and free(out) below, exitting would leak
+ // between here and free(out) below, exiting would leak
temp_out = p;
if (pal_img_n == 3) {
diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig
index 260733b945..b838332b0a 100644
--- a/src/terminal/PageList.zig
+++ b/src/terminal/PageList.zig
@@ -520,6 +520,7 @@ pub fn clone(
assert(node.data.capacity.rows >= chunk.end - chunk.start);
defer node.data.assertIntegrity();
node.data.size.rows = chunk.end - chunk.start;
+ node.data.size.cols = chunk.node.data.size.cols;
try node.data.cloneFrom(
&chunk.node.data,
chunk.start,
diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig
index 9aebdbd3a5..a779c3350e 100644
--- a/src/terminal/Parser.zig
+++ b/src/terminal/Parser.zig
@@ -6,6 +6,7 @@ const Parser = @This();
const std = @import("std");
const builtin = @import("builtin");
+const assert = std.debug.assert;
const testing = std.testing;
const table = @import("parse_table.zig").table;
const osc = @import("osc.zig");
@@ -81,11 +82,15 @@ pub const Action = union(enum) {
pub const CSI = struct {
intermediates: []u8,
params: []u16,
+ params_sep: SepList,
final: u8,
- sep: Sep,
+
+ /// The list of separators used for CSI params. The value of the
+ /// bit can be mapped to Sep.
+ pub const SepList = std.StaticBitSet(MAX_PARAMS);
/// The separator used for CSI params.
- pub const Sep = enum { semicolon, colon };
+ pub const Sep = enum(u1) { semicolon = 0, colon = 1 };
// Implement formatter for logging
pub fn format(
@@ -183,15 +188,6 @@ pub const Action = union(enum) {
}
};
-/// Keeps track of the parameter sep used for CSI params. We allow colons
-/// to be used ONLY by the 'm' CSI action.
-pub const ParamSepState = enum(u8) {
- none = 0,
- semicolon = ';',
- colon = ':',
- mixed = 1,
-};
-
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
@@ -207,8 +203,8 @@ intermediates_idx: u8 = 0,
/// Param tracking, building
params: [MAX_PARAMS]u16 = undefined,
+params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(),
params_idx: u8 = 0,
-params_sep: ParamSepState = .none,
param_acc: u16 = 0,
param_acc_idx: u8 = 0,
@@ -312,13 +308,9 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
// Ignore too many parameters
if (self.params_idx >= MAX_PARAMS) break :param null;
- // If this is our first time seeing a parameter, we track
- // the separator used so that we can't mix separators later.
- if (self.params_idx == 0) self.params_sep = @enumFromInt(c);
- if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed;
-
// Set param final value
self.params[self.params_idx] = self.param_acc;
+ if (c == ':') self.params_sep.set(self.params_idx);
self.params_idx += 1;
// Reset current param value to 0
@@ -359,29 +351,18 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
.csi_dispatch = .{
.intermediates = self.intermediates[0..self.intermediates_idx],
.params = self.params[0..self.params_idx],
+ .params_sep = self.params_sep,
.final = c,
- .sep = switch (self.params_sep) {
- .none, .semicolon => .semicolon,
- .colon => .colon,
-
- // There is nothing that treats mixed separators specially
- // afaik so we just treat it as a semicolon.
- .mixed => .semicolon,
- },
},
};
// We only allow colon or mixed separators for the 'm' command.
- switch (self.params_sep) {
- .none => {},
- .semicolon => {},
- .colon, .mixed => if (c != 'm') {
- log.warn(
- "CSI colon or mixed separators only allowed for 'm' command, got: {}",
- .{result},
- );
- break :csi_dispatch null;
- },
+ if (c != 'm' and self.params_sep.count() > 0) {
+ log.warn(
+ "CSI colon or mixed separators only allowed for 'm' command, got: {}",
+ .{result},
+ );
+ break :csi_dispatch null;
}
break :csi_dispatch result;
@@ -400,7 +381,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
pub fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;
- self.params_sep = .none;
+ self.params_sep = Action.CSI.SepList.initEmpty();
self.param_acc = 0;
self.param_acc_idx = 0;
}
@@ -507,10 +488,11 @@ test "csi: SGR ESC [ 38 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
- try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 38), d.params[0]);
+ try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
+ try testing.expect(!d.params_sep.isSet(1));
}
}
@@ -581,13 +563,17 @@ test "csi: SGR ESC [ 48 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
- try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 5);
try testing.expectEqual(@as(u16, 48), d.params[0]);
+ try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
+ try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 240), d.params[2]);
+ try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 143), d.params[3]);
+ try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 104), d.params[4]);
+ try testing.expect(!d.params_sep.isSet(4));
}
}
@@ -608,10 +594,11 @@ test "csi: SGR ESC [4:3m colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
- try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 4), d.params[0]);
+ try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 3), d.params[1]);
+ try testing.expect(!d.params_sep.isSet(1));
}
}
@@ -634,14 +621,71 @@ test "csi: SGR with many blank and colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
- try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 6);
try testing.expectEqual(@as(u16, 58), d.params[0]);
+ try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
+ try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 0), d.params[2]);
+ try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 240), d.params[3]);
+ try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 143), d.params[4]);
+ try testing.expect(d.params_sep.isSet(4));
try testing.expectEqual(@as(u16, 104), d.params[5]);
+ try testing.expect(!d.params_sep.isSet(5));
+ }
+}
+
+// This is from a Kakoune actual SGR sequence.
+test "csi: SGR mixed colon and semicolon with blank" {
+ var p = init();
+ _ = p.next(0x1B);
+ for ("[;4:3;38;2;175;175;215;58:2::190:80:70") |c| {
+ const a = p.next(c);
+ try testing.expect(a[0] == null);
+ try testing.expect(a[1] == null);
+ try testing.expect(a[2] == null);
+ }
+
+ {
+ const a = p.next('m');
+ try testing.expect(p.state == .ground);
+ try testing.expect(a[0] == null);
+ try testing.expect(a[1].? == .csi_dispatch);
+ try testing.expect(a[2] == null);
+
+ const d = a[1].?.csi_dispatch;
+ try testing.expect(d.final == 'm');
+ try testing.expectEqual(14, d.params.len);
+ try testing.expectEqual(@as(u16, 0), d.params[0]);
+ try testing.expect(!d.params_sep.isSet(0));
+ try testing.expectEqual(@as(u16, 4), d.params[1]);
+ try testing.expect(d.params_sep.isSet(1));
+ try testing.expectEqual(@as(u16, 3), d.params[2]);
+ try testing.expect(!d.params_sep.isSet(2));
+ try testing.expectEqual(@as(u16, 38), d.params[3]);
+ try testing.expect(!d.params_sep.isSet(3));
+ try testing.expectEqual(@as(u16, 2), d.params[4]);
+ try testing.expect(!d.params_sep.isSet(4));
+ try testing.expectEqual(@as(u16, 175), d.params[5]);
+ try testing.expect(!d.params_sep.isSet(5));
+ try testing.expectEqual(@as(u16, 175), d.params[6]);
+ try testing.expect(!d.params_sep.isSet(6));
+ try testing.expectEqual(@as(u16, 215), d.params[7]);
+ try testing.expect(!d.params_sep.isSet(7));
+ try testing.expectEqual(@as(u16, 58), d.params[8]);
+ try testing.expect(d.params_sep.isSet(8));
+ try testing.expectEqual(@as(u16, 2), d.params[9]);
+ try testing.expect(d.params_sep.isSet(9));
+ try testing.expectEqual(@as(u16, 0), d.params[10]);
+ try testing.expect(d.params_sep.isSet(10));
+ try testing.expectEqual(@as(u16, 190), d.params[11]);
+ try testing.expect(d.params_sep.isSet(11));
+ try testing.expectEqual(@as(u16, 80), d.params[12]);
+ try testing.expect(d.params_sep.isSet(12));
+ try testing.expectEqual(@as(u16, 70), d.params[13]);
+ try testing.expect(!d.params_sep.isSet(13));
}
}
diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig
index eb70d32d07..273e1aebec 100644
--- a/src/terminal/Screen.zig
+++ b/src/terminal/Screen.zig
@@ -278,12 +278,9 @@ pub fn reset(self: *Screen) void {
.page_cell = cursor_rac.cell,
};
- // Clear kitty graphics
- self.kitty_images.delete(
- self.alloc,
- undefined, // All image deletion doesn't need the terminal
- .{ .all = true },
- );
+ // Reset kitty graphics storage
+ self.kitty_images.deinit(self.alloc, self);
+ self.kitty_images = .{ .dirty = true };
// Reset our basic state
self.saved_cursor = null;
@@ -474,26 +471,42 @@ pub fn adjustCapacity(
const new_node = try self.pages.adjustCapacity(node, adjustment);
const new_page: *Page = &new_node.data;
- // All additions below have unreachable catches because when
- // we adjust cap we should have enough memory to fit the
- // existing data.
-
- // Re-add the style
+ // Re-add the style, if the page somehow doesn't have enough
+ // memory to add it, we emit a warning and gracefully degrade
+ // to the default style for the cursor.
if (self.cursor.style_id != 0) {
self.cursor.style_id = new_page.styles.add(
new_page.memory,
self.cursor.style,
- ) catch unreachable;
+ ) catch |err| id: {
+ // TODO: Should we increase the capacity further in this case?
+ log.warn(
+ "(Screen.adjustCapacity) Failed to add cursor style back to page, err={}",
+ .{err},
+ );
+
+ // Reset the cursor style.
+ self.cursor.style = .{};
+ break :id style.default_id;
+ };
}
- // Re-add the hyperlink
+ // Re-add the hyperlink, if the page somehow doesn't have enough
+ // memory to add it, we emit a warning and gracefully degrade to
+ // no hyperlink.
if (self.cursor.hyperlink) |link| {
// So we don't attempt to free any memory in the replaced page.
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
// Re-add
- self.startHyperlinkOnce(link.*) catch unreachable;
+ self.startHyperlinkOnce(link.*) catch |err| {
+ // TODO: Should we increase the capacity further in this case?
+ log.warn(
+ "(Screen.adjustCapacity) Failed to add cursor hyperlink back to page, err={}",
+ .{err},
+ );
+ };
// Remove our old link
link.deinit(self.alloc);
@@ -1003,8 +1016,9 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct {
/// Always use this to write to cursor.page_pin.*.
///
/// This specifically handles the case when the new pin is on a different
-/// page than the old AND we have a style set. In that case, we must release
-/// our old style and upsert our new style since styles are stored per-page.
+/// page than the old AND we have a style or hyperlink set. In that case,
+/// we must release our old one and insert the new one, since styles are
+/// stored per-page.
fn cursorChangePin(self: *Screen, new: Pin) void {
// Moving the cursor affects text run splitting (ligatures) so
// we must mark the old and new page dirty. We do this as long
@@ -1576,6 +1590,18 @@ fn resizeInternal(
self.cursor.hyperlink = null;
}
+ // We need to insert a tracked pin for our saved cursor so we can
+ // modify its X/Y for reflow.
+ const saved_cursor_pin: ?*Pin = saved_cursor: {
+ const sc = self.saved_cursor orelse break :saved_cursor null;
+ const pin = self.pages.pin(.{ .active = .{
+ .x = sc.x,
+ .y = sc.y,
+ } }) orelse break :saved_cursor null;
+ break :saved_cursor try self.pages.trackPin(pin);
+ };
+ defer if (saved_cursor_pin) |p| self.pages.untrackPin(p);
+
// Perform the resize operation.
try self.pages.resize(.{
.rows = rows,
@@ -1595,6 +1621,36 @@ fn resizeInternal(
// state is correct.
self.cursorReload();
+ // If we reflowed a saved cursor, update it.
+ if (saved_cursor_pin) |p| {
+ // This should never fail because a non-null saved_cursor_pin
+ // implies a non-null saved_cursor.
+ const sc = &self.saved_cursor.?;
+ if (self.pages.pointFromPin(.active, p.*)) |pt| {
+ sc.x = @intCast(pt.active.x);
+ sc.y = @intCast(pt.active.y);
+
+ // If we had pending wrap set and we're no longer at the end of
+ // the line, we unset the pending wrap and move the cursor to
+ // reflect the correct next position.
+ if (sc.pending_wrap and sc.x != cols - 1) {
+ sc.pending_wrap = false;
+ sc.x += 1;
+ }
+ } else {
+ // I think this can happen if the screen is resized to be
+ // less rows or less cols and our saved cursor moves outside
+ // the active area. In this case, there isn't anything really
+ // reasonable we can do so we just move the cursor to the
+ // top-left. It may be reasonable to also move the cursor to
+ // match the primary cursor. Any behavior is fine since this is
+ // totally unspecified.
+ sc.x = 0;
+ sc.y = 0;
+ sc.pending_wrap = false;
+ }
+ }
+
// Fix up our hyperlink if we had one.
if (hyperlink_) |link| {
self.startHyperlink(link.uri, switch (link.id) {
@@ -1986,9 +2042,40 @@ pub fn cursorSetHyperlink(self: *Screen) !void {
} else |err| switch (err) {
// hyperlink_map is out of space, realloc the page to be larger
error.HyperlinkMapOutOfMemory => {
+ const uri_size = if (self.cursor.hyperlink) |link| link.uri.len else 0;
+
+ var string_bytes = page.capacity.string_bytes;
+
+ // Attempt to allocate the space that would be required to
+ // insert a new copy of the cursor hyperlink uri in to the
+ // string alloc, since right now adjustCapacity always just
+ // adds an extra copy even if one already exists in the page.
+ // If this alloc fails then we know we also need to grow our
+ // string bytes.
+ //
+ // FIXME: This SUCKS
+ if (page.string_alloc.alloc(
+ u8,
+ page.memory,
+ uri_size,
+ )) |slice| {
+ // We don't bother freeing because we're
+ // about to free the entire page anyway.
+ _ = &slice;
+ } else |_| {
+ // We didn't have enough room, let's just double our
+ // string bytes until there's definitely enough room
+ // for our uri.
+ const before = string_bytes;
+ while (string_bytes - before < uri_size) string_bytes *= 2;
+ }
+
_ = try self.adjustCapacity(
self.cursor.page_pin.node,
- .{ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2 },
+ .{
+ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2,
+ .string_bytes = string_bytes,
+ },
);
// Retry
@@ -2591,13 +2678,36 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection {
const start: Pin = boundary: {
var it = pin.rowIterator(.left_up, null);
var it_prev = pin;
+
+ // First, iterate until we find the first line of command output
while (it.next()) |p| {
+ it_prev = p;
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
- .command => break :boundary p,
- else => {},
+ .command => break,
+
+ .unknown,
+ .prompt,
+ .prompt_continuation,
+ .input,
+ => {},
}
+ }
+ // Because the first line of command output may span multiple visual rows we must now
+ // iterate until we find the first row of anything other than command output and then
+ // yield the previous row.
+ while (it.next()) |p| {
+ const row = p.rowAndCell().row;
+ switch (row.semantic_prompt) {
+ .command => {},
+
+ .unknown,
+ .prompt,
+ .prompt_continuation,
+ .input,
+ => break :boundary it_prev,
+ }
it_prev = p;
}
@@ -2859,6 +2969,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.protected = self.cursor.protected,
};
+ // If we have a hyperlink, add it to the cell.
+ if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
+
// If we have a ref-counted style, increase.
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.node.data;
@@ -2877,6 +2990,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.protected = self.cursor.protected,
};
+ // If we have a hyperlink, add it to the cell.
+ if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
+
self.cursor.page_row.wrap = true;
try self.cursorDownOrScroll();
self.cursorHorizontalAbsolute(0);
@@ -2892,6 +3008,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.protected = self.cursor.protected,
};
+ // If we have a hyperlink, add it to the cell.
+ if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
+
// Write our tail
self.cursorRight(1);
self.cursor.page_cell.* = .{
@@ -2901,6 +3020,9 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.protected = self.cursor.protected,
};
+ // If we have a hyperlink, add it to the cell.
+ if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
+
// If we have a ref-counted style, increase twice.
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.node.data;
@@ -7641,17 +7763,17 @@ test "Screen: selectOutput" {
// zig fmt: off
{
- // line number:
- try s.testWriteString("output1\n"); // 0
- try s.testWriteString("output1\n"); // 1
- try s.testWriteString("prompt2\n"); // 2
- try s.testWriteString("input2\n"); // 3
- try s.testWriteString("output2\n"); // 4
- try s.testWriteString("output2\n"); // 5
- try s.testWriteString("prompt3$ input3\n"); // 6
- try s.testWriteString("output3\n"); // 7
- try s.testWriteString("output3\n"); // 8
- try s.testWriteString("output3"); // 9
+ // line number:
+ try s.testWriteString("output1\n"); // 0
+ try s.testWriteString("output1\n"); // 1
+ try s.testWriteString("prompt2\n"); // 2
+ try s.testWriteString("input2\n"); // 3
+ try s.testWriteString("output2output2output2output2\n"); // 4, 5, 6 due to overflow
+ try s.testWriteString("output2\n"); // 7
+ try s.testWriteString("prompt3$ input3\n"); // 8
+ try s.testWriteString("output3\n"); // 9
+ try s.testWriteString("output3\n"); // 10
+ try s.testWriteString("output3"); // 11
}
// zig fmt: on
@@ -7670,13 +7792,23 @@ test "Screen: selectOutput" {
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
+ {
+ const pin = s.pages.pin(.{ .screen = .{ .y = 5 } }).?;
+ const row = pin.rowAndCell().row;
+ row.semantic_prompt = .command;
+ }
{
const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?;
const row = pin.rowAndCell().row;
+ row.semantic_prompt = .command;
+ }
+ {
+ const pin = s.pages.pin(.{ .screen = .{ .y = 8 } }).?;
+ const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
- const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?;
+ const pin = s.pages.pin(.{ .screen = .{ .y = 9 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
@@ -7701,7 +7833,7 @@ test "Screen: selectOutput" {
{
var sel = s.selectOutput(s.pages.pin(.{ .active = .{
.x = 3,
- .y = 5,
+ .y = 7,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
@@ -7710,23 +7842,23 @@ test "Screen: selectOutput" {
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 9,
- .y = 5,
+ .y = 7,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// No end marker, should select till the end
{
var sel = s.selectOutput(s.pages.pin(.{ .active = .{
.x = 2,
- .y = 7,
+ .y = 10,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
- .y = 7,
+ .y = 9,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 9,
- .y = 10,
+ .y = 12,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// input / prompt at y = 0, pt.y = 0
@@ -8692,6 +8824,40 @@ test "Screen: hyperlink cursor state on resize" {
}
}
+test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ // Start a hyperlink with a URI that just barely fits in the string alloc.
+ // This will ensure that additional string alloc space is needed for the
+ // redundant copy of the URI when the page is re-alloced.
+ const uri = "a" ** (pagepkg.std_capacity.string_bytes - 8);
+ try s.startHyperlink(uri, null);
+
+ // Figure out how many cells should can have hyperlinks in this page,
+ // and write twice that number, to guarantee the capacity needs to be
+ // increased at some point.
+ const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity();
+ const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes;
+ for (0..base_capacity * 2) |_| {
+ try s.cursorSetHyperlink();
+ if (s.cursor.x >= s.pages.cols - 1) {
+ try s.cursorDownOrScroll();
+ s.cursorHorizontalAbsolute(0);
+ } else {
+ s.cursorRight(1);
+ }
+ }
+
+ // Make sure the capacity really did increase.
+ try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity());
+ // And that our string_bytes increased as well.
+ try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes);
+}
+
test "Screen: adjustCapacity cursor style ref count" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -8726,6 +8892,102 @@ test "Screen: adjustCapacity cursor style ref count" {
}
}
+test "Screen: adjustCapacity cursor hyperlink exceeds string alloc size" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ // Start a hyperlink with a URI that just barely fits in the string alloc.
+ // This will ensure that the redundant copy added in `adjustCapacity` won't
+ // fit in the available string alloc space.
+ const uri = "a" ** (pagepkg.std_capacity.string_bytes - 8);
+ try s.startHyperlink(uri, null);
+
+ // Write some characters with this so that the URI
+ // is copied to the new page when adjusting capacity.
+ try s.testWriteString("Hello");
+
+ // Adjust the capacity, right now this will cause a redundant copy of
+ // the URI to be added to the string alloc, but since there isn't room
+ // for this this will clear the cursor hyperlink.
+ _ = try s.adjustCapacity(s.cursor.page_pin.node, .{});
+
+ // The cursor hyperlink should have been cleared by the `adjustCapacity`
+ // call, because there isn't enough room to add the redundant URI string.
+ //
+ // This behavior will change, causing this test to fail, if any of these
+ // changes are made:
+ //
+ // - The string alloc is changed to intern strings.
+ //
+ // - The adjustCapacity function is changed to ensure the new
+ // capacity will fit the redundant copy of the hyperlink uri.
+ //
+ // - The cursor managed memory handling is reworked so that it
+ // doesn't reside in the pages anymore and doesn't need this
+ // accounting.
+ //
+ // In such a case, adjust this test accordingly.
+ try testing.expectEqual(null, s.cursor.hyperlink);
+ try testing.expectEqual(0, s.cursor.hyperlink_id);
+}
+
+test "Screen: adjustCapacity cursor style exceeds style set capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 1000);
+ defer s.deinit();
+
+ const page = &s.cursor.page_pin.node.data;
+
+ // We add unique styles to the page until no more will fit.
+ fill: for (0..255) |bg| {
+ for (0..255) |fg| {
+ const st: style.Style = .{
+ .bg_color = .{ .palette = @intCast(bg) },
+ .fg_color = .{ .palette = @intCast(fg) },
+ };
+
+ s.cursor.style = st;
+
+ // Try to insert the new style, if it doesn't fit then
+ // we succeeded in filling the style set, so we break.
+ s.cursor.style_id = page.styles.add(
+ page.memory,
+ s.cursor.style,
+ ) catch break :fill;
+
+ try s.testWriteString("a");
+ }
+ }
+
+ // Adjust the capacity, this should cause the style set to reach the
+ // same state it was in to begin with, since it will clone the page
+ // in the same order as the styles were added to begin with, meaning
+ // the cursor style will not be able to be added to the set, which
+ // should, right now, result in the cursor style being cleared.
+ _ = try s.adjustCapacity(s.cursor.page_pin.node, .{});
+
+ // The cursor style should have been cleared by the `adjustCapacity`.
+ //
+ // This behavior will change, causing this test to fail, if either
+ // of these changes are made:
+ //
+ // - The adjustCapacity function is changed to ensure the
+ // new capacity will definitely fit the cursor style.
+ //
+ // - The cursor managed memory handling is reworked so that it
+ // doesn't reside in the pages anymore and doesn't need this
+ // accounting.
+ //
+ // In such a case, adjust this test accordingly.
+ try testing.expect(s.cursor.style.default());
+ try testing.expectEqual(style.default_id, s.cursor.style_id);
+}
+
test "Screen UTF8 cell map with newlines" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig
index 65476108d7..bec0a24a23 100644
--- a/src/terminal/Terminal.zig
+++ b/src/terminal/Terminal.zig
@@ -10708,6 +10708,87 @@ test "Terminal: resize with high unique style per cell with wrapping" {
try t.resize(alloc, 60, 30);
}
+test "Terminal: resize with reflow and saved cursor" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 2, .rows = 3 });
+ defer t.deinit(alloc);
+ try t.printString("1A2B");
+ t.setCursorPos(2, 2);
+ {
+ const list_cell = t.screen.pages.getCell(.{ .active = .{
+ .x = t.screen.cursor.x,
+ .y = t.screen.cursor.y,
+ } }).?;
+ const cell = list_cell.cell;
+ try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
+ }
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A\n2B", str);
+ }
+
+ t.saveCursor();
+ try t.resize(alloc, 5, 3);
+ try t.restoreCursor();
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A2B", str);
+ }
+
+ // Verify our cursor is still in the same place
+ {
+ const list_cell = t.screen.pages.getCell(.{ .active = .{
+ .x = t.screen.cursor.x,
+ .y = t.screen.cursor.y,
+ } }).?;
+ const cell = list_cell.cell;
+ try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
+ }
+}
+
+test "Terminal: resize with reflow and saved cursor pending wrap" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 2, .rows = 3 });
+ defer t.deinit(alloc);
+ try t.printString("1A2B");
+ {
+ const list_cell = t.screen.pages.getCell(.{ .active = .{
+ .x = t.screen.cursor.x,
+ .y = t.screen.cursor.y,
+ } }).?;
+ const cell = list_cell.cell;
+ try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint);
+ }
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A\n2B", str);
+ }
+
+ t.saveCursor();
+ try t.resize(alloc, 5, 3);
+ try t.restoreCursor();
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A2B", str);
+ }
+
+ // Pending wrap should be reset
+ try t.print('X');
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A2BX", str);
+ }
+}
+
test "Terminal: DECCOLM without DEC mode 40" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig
index 1ab3c5ea74..bb9e78ca65 100644
--- a/src/terminal/hyperlink.zig
+++ b/src/terminal/hyperlink.zig
@@ -194,14 +194,24 @@ pub const Set = RefCountedSet(
Id,
size.CellCountInt,
struct {
+ /// The page which holds the strings for items in this set.
page: ?*Page = null,
+ /// The page which holds the strings for items
+ /// looked up with, e.g., `add` or `lookup`,
+ /// if different from the destination page.
+ src_page: ?*const Page = null,
+
pub fn hash(self: *const @This(), link: PageEntry) u64 {
- return link.hash(self.page.?.memory);
+ return link.hash((self.src_page orelse self.page.?).memory);
}
pub fn eql(self: *const @This(), a: PageEntry, b: PageEntry) bool {
- return a.eql(self.page.?.memory, &b, self.page.?.memory);
+ return a.eql(
+ (self.src_page orelse self.page.?).memory,
+ &b,
+ self.page.?.memory,
+ );
}
pub fn deleted(self: *const @This(), link: PageEntry) void {
diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig
index 10ba5b5e77..faf376d13d 100644
--- a/src/terminal/osc.zig
+++ b/src/terminal/osc.zig
@@ -272,6 +272,9 @@ pub const Parser = struct {
// Maximum length of a single OSC command. This is the full OSC command
// sequence length (excluding ESC ]). This is arbitrary, I couldn't find
// any definitive resource on how long this should be.
+ //
+ // NOTE: This does mean certain OSC sequences such as OSC 8 (hyperlinks)
+ // won't work if their parameters are larger than fit in the buffer.
const MAX_BUF = 2048;
pub const State = enum {
@@ -425,9 +428,23 @@ pub const Parser = struct {
/// Consume the next character c and advance the parser state.
pub fn next(self: *Parser, c: u8) void {
- // If our buffer is full then we're invalid.
+ // If our buffer is full then we're invalid, so we set our state
+ // accordingly and indicate the sequence is incomplete so that we
+ // don't accidentally issue a command when ending.
if (self.buf_idx >= self.buf.len) {
+ if (self.state != .invalid) {
+ log.warn(
+ "OSC sequence too long (> {d}), ignoring. state={}",
+ .{ self.buf.len, self.state },
+ );
+ }
+
self.state = .invalid;
+
+ // We have to do this here because it will never reach the
+ // switch statement below, since our buf_idx will always be
+ // too high after this.
+ self.complete = false;
return;
}
@@ -1643,10 +1660,11 @@ test "OSC: longer than buffer" {
var p: Parser = .{};
- const input = "a" ** (Parser.MAX_BUF + 2);
+ const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2);
for (input) |ch| p.next(ch);
try testing.expect(p.end(null) == null);
+ try testing.expect(p.complete == false);
}
test "OSC: report default foreground color" {
diff --git a/src/terminal/page.zig b/src/terminal/page.zig
index ae14b8c016..30f6658aa5 100644
--- a/src/terminal/page.zig
+++ b/src/terminal/page.zig
@@ -821,11 +821,7 @@ pub const Page = struct {
if (self.hyperlink_set.lookupContext(
self.memory,
other_link.*,
-
- // `lookupContext` uses the context for hashing, and
- // that doesn't write to the page, so this constCast
- // is completely safe.
- .{ .page = @constCast(other) },
+ .{ .page = self, .src_page = @constCast(other) },
)) |i| {
self.hyperlink_set.use(self.memory, i);
break :dst_id i;
diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig
index 1a58a4e5b5..b674295dcb 100644
--- a/src/terminal/ref_counted_set.zig
+++ b/src/terminal/ref_counted_set.zig
@@ -38,8 +38,14 @@ const fastmem = @import("../fastmem.zig");
///
/// `Context`
/// A type containing methods to define behaviors.
+///
/// - `fn hash(*Context, T) u64` - Return a hash for an item.
+///
/// - `fn eql(*Context, T, T) bool` - Check two items for equality.
+/// The first of the two items passed in is guaranteed to be from
+/// a value passed in to an `add` or `lookup` function, the second
+/// is guaranteed to be a value already resident in the set.
+///
/// - `fn deleted(*Context, T) void` - [OPTIONAL] Deletion callback.
/// If present, called whenever an item is finally deleted.
/// Useful if the item has memory that needs to be freed.
diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig
index cdf39657bd..52bfb2c31a 100644
--- a/src/terminal/sgr.zig
+++ b/src/terminal/sgr.zig
@@ -1,13 +1,17 @@
//! SGR (Select Graphic Rendition) attrinvbute parsing and types.
const std = @import("std");
+const assert = std.debug.assert;
const testing = std.testing;
const color = @import("color.zig");
+const SepList = @import("Parser.zig").Action.CSI.SepList;
/// Attribute type for SGR
pub const Attribute = union(enum) {
+ pub const Tag = std.meta.FieldEnum(Attribute);
+
/// Unset all attributes
- unset: void,
+ unset,
/// Unknown attribute, the raw CSI command parameters are here.
unknown: struct {
@@ -19,43 +23,43 @@ pub const Attribute = union(enum) {
},
/// Bold the text.
- bold: void,
- reset_bold: void,
+ bold,
+ reset_bold,
/// Italic text.
- italic: void,
- reset_italic: void,
+ italic,
+ reset_italic,
/// Faint/dim text.
/// Note: reset faint is the same SGR code as reset bold
- faint: void,
+ faint,
/// Underline the text
underline: Underline,
- reset_underline: void,
+ reset_underline,
underline_color: color.RGB,
@"256_underline_color": u8,
- reset_underline_color: void,
+ reset_underline_color,
// Overline the text
- overline: void,
- reset_overline: void,
+ overline,
+ reset_overline,
/// Blink the text
- blink: void,
- reset_blink: void,
+ blink,
+ reset_blink,
/// Invert fg/bg colors.
- inverse: void,
- reset_inverse: void,
+ inverse,
+ reset_inverse,
/// Invisible
- invisible: void,
- reset_invisible: void,
+ invisible,
+ reset_invisible,
/// Strikethrough the text.
- strikethrough: void,
- reset_strikethrough: void,
+ strikethrough,
+ reset_strikethrough,
/// Set foreground color as RGB values.
direct_color_fg: color.RGB,
@@ -68,8 +72,8 @@ pub const Attribute = union(enum) {
@"8_fg": color.Name,
/// Reset the fg/bg to their default values.
- reset_fg: void,
- reset_bg: void,
+ reset_fg,
+ reset_bg,
/// Set the background/foreground as a named bright color attribute.
@"8_bright_bg": color.Name,
@@ -94,11 +98,9 @@ pub const Attribute = union(enum) {
/// Parser parses the attributes from a list of SGR parameters.
pub const Parser = struct {
params: []const u16,
+ params_sep: SepList = SepList.initEmpty(),
idx: usize = 0,
- /// True if the separator is a colon
- colon: bool = false,
-
/// Next returns the next attribute or null if there are no more attributes.
pub fn next(self: *Parser) ?Attribute {
if (self.idx > self.params.len) return null;
@@ -106,220 +108,261 @@ pub const Parser = struct {
// Implicitly means unset
if (self.params.len == 0) {
self.idx += 1;
- return Attribute{ .unset = {} };
+ return .unset;
}
const slice = self.params[self.idx..self.params.len];
+ const colon = self.params_sep.isSet(self.idx);
self.idx += 1;
// Our last one will have an idx be the last value.
if (slice.len == 0) return null;
+ // If we have a colon separator then we need to ensure we're
+ // parsing a value that allows it.
+ if (colon) switch (slice[0]) {
+ 4, 38, 48, 58 => {},
+
+ else => {
+ // Consume all the colon separated values.
+ const start = self.idx;
+ while (self.params_sep.isSet(self.idx)) self.idx += 1;
+ self.idx += 1;
+ return .{ .unknown = .{
+ .full = self.params,
+ .partial = slice[0 .. self.idx - start + 1],
+ } };
+ },
+ };
+
switch (slice[0]) {
- 0 => return Attribute{ .unset = {} },
-
- 1 => return Attribute{ .bold = {} },
-
- 2 => return Attribute{ .faint = {} },
-
- 3 => return Attribute{ .italic = {} },
-
- 4 => blk: {
- if (self.colon) {
- switch (slice.len) {
- // 0 is unreachable because we're here and we read
- // an element to get here.
- 0 => unreachable,
-
- // 1 is possible if underline is the last element.
- 1 => return Attribute{ .underline = .single },
-
- // 2 means we have a specific underline style.
- 2 => {
- self.idx += 1;
- switch (slice[1]) {
- 0 => return Attribute{ .reset_underline = {} },
- 1 => return Attribute{ .underline = .single },
- 2 => return Attribute{ .underline = .double },
- 3 => return Attribute{ .underline = .curly },
- 4 => return Attribute{ .underline = .dotted },
- 5 => return Attribute{ .underline = .dashed },
-
- // For unknown underline styles, just render
- // a single underline.
- else => return Attribute{ .underline = .single },
- }
- },
-
- // Colon-separated must only be 2.
- else => break :blk,
+ 0 => return .unset,
+
+ 1 => return .bold,
+
+ 2 => return .faint,
+
+ 3 => return .italic,
+
+ 4 => underline: {
+ if (colon) {
+ assert(slice.len >= 2);
+ if (self.isColon()) {
+ self.consumeUnknownColon();
+ break :underline;
+ }
+
+ self.idx += 1;
+ switch (slice[1]) {
+ 0 => return .reset_underline,
+ 1 => return .{ .underline = .single },
+ 2 => return .{ .underline = .double },
+ 3 => return .{ .underline = .curly },
+ 4 => return .{ .underline = .dotted },
+ 5 => return .{ .underline = .dashed },
+
+ // For unknown underline styles, just render
+ // a single underline.
+ else => return .{ .underline = .single },
}
}
- return Attribute{ .underline = .single };
+ return .{ .underline = .single };
},
- 5 => return Attribute{ .blink = {} },
+ 5 => return .blink,
- 6 => return Attribute{ .blink = {} },
+ 6 => return .blink,
- 7 => return Attribute{ .inverse = {} },
+ 7 => return .inverse,
- 8 => return Attribute{ .invisible = {} },
+ 8 => return .invisible,
- 9 => return Attribute{ .strikethrough = {} },
+ 9 => return .strikethrough,
- 21 => return Attribute{ .underline = .double },
+ 21 => return .{ .underline = .double },
- 22 => return Attribute{ .reset_bold = {} },
+ 22 => return .reset_bold,
- 23 => return Attribute{ .reset_italic = {} },
+ 23 => return .reset_italic,
- 24 => return Attribute{ .reset_underline = {} },
+ 24 => return .reset_underline,
- 25 => return Attribute{ .reset_blink = {} },
+ 25 => return .reset_blink,
- 27 => return Attribute{ .reset_inverse = {} },
+ 27 => return .reset_inverse,
- 28 => return Attribute{ .reset_invisible = {} },
+ 28 => return .reset_invisible,
- 29 => return Attribute{ .reset_strikethrough = {} },
+ 29 => return .reset_strikethrough,
- 30...37 => return Attribute{
+ 30...37 => return .{
.@"8_fg" = @enumFromInt(slice[0] - 30),
},
38 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
- 2 => if (slice.len >= 5) {
- self.idx += 4;
- // When a colon separator is used, there may or may not be
- // a color space identifier as the third param, which we
- // need to ignore (it has no standardized behavior).
- const rgb = if (slice.len == 5 or !self.colon)
- slice[2..5]
- else rgb: {
- self.idx += 1;
- break :rgb slice[3..6];
- };
+ 2 => if (self.parseDirectColor(
+ .direct_color_fg,
+ slice,
+ colon,
+ )) |v| return v,
- // We use @truncate because the value should be 0 to 255. If
- // it isn't, the behavior is undefined so we just... truncate it.
- return Attribute{
- .direct_color_fg = .{
- .r = @truncate(rgb[0]),
- .g = @truncate(rgb[1]),
- .b = @truncate(rgb[2]),
- },
- };
- },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
- return Attribute{
+ return .{
.@"256_fg" = @truncate(slice[2]),
};
},
else => {},
},
- 39 => return Attribute{ .reset_fg = {} },
+ 39 => return .reset_fg,
- 40...47 => return Attribute{
+ 40...47 => return .{
.@"8_bg" = @enumFromInt(slice[0] - 40),
},
48 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
- 2 => if (slice.len >= 5) {
- self.idx += 4;
- // When a colon separator is used, there may or may not be
- // a color space identifier as the third param, which we
- // need to ignore (it has no standardized behavior).
- const rgb = if (slice.len == 5 or !self.colon)
- slice[2..5]
- else rgb: {
- self.idx += 1;
- break :rgb slice[3..6];
- };
+ 2 => if (self.parseDirectColor(
+ .direct_color_bg,
+ slice,
+ colon,
+ )) |v| return v,
- // We use @truncate because the value should be 0 to 255. If
- // it isn't, the behavior is undefined so we just... truncate it.
- return Attribute{
- .direct_color_bg = .{
- .r = @truncate(rgb[0]),
- .g = @truncate(rgb[1]),
- .b = @truncate(rgb[2]),
- },
- };
- },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
- return Attribute{
+ return .{
.@"256_bg" = @truncate(slice[2]),
};
},
else => {},
},
- 49 => return Attribute{ .reset_bg = {} },
+ 49 => return .reset_bg,
- 53 => return Attribute{ .overline = {} },
- 55 => return Attribute{ .reset_overline = {} },
+ 53 => return .overline,
+ 55 => return .reset_overline,
58 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
- 2 => if (slice.len >= 5) {
- self.idx += 4;
- // When a colon separator is used, there may or may not be
- // a color space identifier as the third param, which we
- // need to ignore (it has no standardized behavior).
- const rgb = if (slice.len == 5 or !self.colon)
- slice[2..5]
- else rgb: {
- self.idx += 1;
- break :rgb slice[3..6];
- };
+ 2 => if (self.parseDirectColor(
+ .underline_color,
+ slice,
+ colon,
+ )) |v| return v,
- // We use @truncate because the value should be 0 to 255. If
- // it isn't, the behavior is undefined so we just... truncate it.
- return Attribute{
- .underline_color = .{
- .r = @truncate(rgb[0]),
- .g = @truncate(rgb[1]),
- .b = @truncate(rgb[2]),
- },
- };
- },
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
- return Attribute{
+ return .{
.@"256_underline_color" = @truncate(slice[2]),
};
},
else => {},
},
- 59 => return Attribute{ .reset_underline_color = {} },
+ 59 => return .reset_underline_color,
- 90...97 => return Attribute{
+ 90...97 => return .{
// 82 instead of 90 to offset to "bright" colors
.@"8_bright_fg" = @enumFromInt(slice[0] - 82),
},
- 100...107 => return Attribute{
+ 100...107 => return .{
.@"8_bright_bg" = @enumFromInt(slice[0] - 92),
},
else => {},
}
- return Attribute{ .unknown = .{ .full = self.params, .partial = slice } };
+ return .{ .unknown = .{ .full = self.params, .partial = slice } };
+ }
+
+ fn parseDirectColor(
+ self: *Parser,
+ comptime tag: Attribute.Tag,
+ slice: []const u16,
+ colon: bool,
+ ) ?Attribute {
+ // Any direct color style must have at least 5 values.
+ if (slice.len < 5) return null;
+
+ // Only used for direct color sets (38, 48, 58) and subparam 2.
+ assert(slice[1] == 2);
+
+ // Note: We use @truncate because the value should be 0 to 255. If
+ // it isn't, the behavior is undefined so we just... truncate it.
+
+ // If we don't have a colon, then we expect exactly 3 semicolon
+ // separated values.
+ if (!colon) {
+ self.idx += 4;
+ return @unionInit(Attribute, @tagName(tag), .{
+ .r = @truncate(slice[2]),
+ .g = @truncate(slice[3]),
+ .b = @truncate(slice[4]),
+ });
+ }
+
+ // We have a colon, we might have either 5 or 6 values depending
+ // on if the colorspace is present.
+ const count = self.countColon();
+ switch (count) {
+ 3 => {
+ self.idx += 4;
+ return @unionInit(Attribute, @tagName(tag), .{
+ .r = @truncate(slice[2]),
+ .g = @truncate(slice[3]),
+ .b = @truncate(slice[4]),
+ });
+ },
+
+ 4 => {
+ self.idx += 5;
+ return @unionInit(Attribute, @tagName(tag), .{
+ .r = @truncate(slice[3]),
+ .g = @truncate(slice[4]),
+ .b = @truncate(slice[5]),
+ });
+ },
+
+ else => {
+ self.consumeUnknownColon();
+ return null;
+ },
+ }
+ }
+
+ /// Returns true if the present position has a colon separator.
+ /// This always returns false for the last value since it has no
+ /// separator.
+ fn isColon(self: *Parser) bool {
+ // The `- 1` here is because the last value has no separator.
+ if (self.idx >= self.params.len - 1) return false;
+ return self.params_sep.isSet(self.idx);
+ }
+
+ fn countColon(self: *Parser) usize {
+ var count: usize = 0;
+ var idx = self.idx;
+ while (idx < self.params.len - 1 and self.params_sep.isSet(idx)) : (idx += 1) {
+ count += 1;
+ }
+ return count;
+ }
+
+ /// Consumes all the remaining parameters separated by a colon and
+ /// returns an unknown attribute.
+ fn consumeUnknownColon(self: *Parser) void {
+ const count = self.countColon();
+ self.idx += count + 1;
}
};
@@ -329,7 +372,7 @@ fn testParse(params: []const u16) Attribute {
}
fn testParseColon(params: []const u16) Attribute {
- var p: Parser = .{ .params = params, .colon = true };
+ var p: Parser = .{ .params = params, .params_sep = SepList.initFull() };
return p.next().?;
}
@@ -366,6 +409,35 @@ test "sgr: Parser multiple" {
try testing.expect(p.next() == null);
}
+test "sgr: unsupported with colon" {
+ var p: Parser = .{
+ .params = &[_]u16{ 0, 4, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ list.set(0);
+ break :sep list;
+ },
+ };
+ try testing.expect(p.next().? == .unknown);
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
+test "sgr: unsupported with multiple colon" {
+ var p: Parser = .{
+ .params = &[_]u16{ 0, 4, 2, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ list.set(0);
+ list.set(1);
+ break :sep list;
+ },
+ };
+ try testing.expect(p.next().? == .unknown);
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
test "sgr: bold" {
{
const v = testParse(&[_]u16{1});
@@ -439,6 +511,37 @@ test "sgr: underline styles" {
}
}
+test "sgr: underline style with more" {
+ var p: Parser = .{
+ .params = &[_]u16{ 4, 2, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ list.set(0);
+ break :sep list;
+ },
+ };
+
+ try testing.expect(p.next().? == .underline);
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
+test "sgr: underline style with too many colons" {
+ var p: Parser = .{
+ .params = &[_]u16{ 4, 2, 3, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ list.set(0);
+ list.set(1);
+ break :sep list;
+ },
+ };
+
+ try testing.expect(p.next().? == .unknown);
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
test "sgr: blink" {
{
const v = testParse(&[_]u16{5});
@@ -592,13 +695,13 @@ test "sgr: underline, bg, and fg" {
test "sgr: direct color fg missing color" {
// This used to crash
- var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false };
+ var p: Parser = .{ .params = &[_]u16{ 38, 5 } };
while (p.next()) |_| {}
}
test "sgr: direct color bg missing color" {
// This used to crash
- var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
+ var p: Parser = .{ .params = &[_]u16{ 48, 5 } };
while (p.next()) |_| {}
}
@@ -608,7 +711,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Colon version should skip the optional color space identifier
{
// 3 8 : 2 : Pi : Pr : Pg : Pb
- const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
+ const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
@@ -616,7 +719,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 : 2 : Pi : Pr : Pg : Pb
- const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
+ const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g);
@@ -624,7 +727,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 : 2 : Pi : Pr : Pg : Pb
- const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
+ const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 1), v.underline_color.r);
try testing.expectEqual(@as(u8, 2), v.underline_color.g);
@@ -634,7 +737,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Semicolon version should not parse optional color space identifier
{
// 3 8 ; 2 ; Pr ; Pg ; Pb
- const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
+ const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g);
@@ -642,7 +745,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 ; 2 ; Pr ; Pg ; Pb
- const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
+ const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g);
@@ -650,10 +753,114 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 ; 2 ; Pr ; Pg ; Pb
- const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
+ const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 0), v.underline_color.r);
try testing.expectEqual(@as(u8, 1), v.underline_color.g);
try testing.expectEqual(@as(u8, 2), v.underline_color.b);
}
}
+
+test "sgr: direct fg colon with too many colons" {
+ var p: Parser = .{
+ .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 4, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ for (0..6) |idx| list.set(idx);
+ break :sep list;
+ },
+ };
+
+ try testing.expect(p.next().? == .unknown);
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
+test "sgr: direct fg colon with colorspace and extra param" {
+ var p: Parser = .{
+ .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ for (0..5) |idx| list.set(idx);
+ break :sep list;
+ },
+ };
+
+ {
+ const v = p.next().?;
+ std.log.warn("WHAT={}", .{v});
+ try testing.expect(v == .direct_color_fg);
+ try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
+ try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
+ try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
+ }
+
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
+test "sgr: direct fg colon no colorspace and extra param" {
+ var p: Parser = .{
+ .params = &[_]u16{ 38, 2, 1, 2, 3, 1 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ for (0..4) |idx| list.set(idx);
+ break :sep list;
+ },
+ };
+
+ {
+ const v = p.next().?;
+ try testing.expect(v == .direct_color_fg);
+ try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
+ try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
+ try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
+ }
+
+ try testing.expect(p.next().? == .bold);
+ try testing.expect(p.next() == null);
+}
+
+// Kakoune sent this complex SGR sequence that caused invalid behavior.
+test "sgr: kakoune input" {
+ // This used to crash
+ var p: Parser = .{
+ .params = &[_]u16{ 0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70 },
+ .params_sep = sep: {
+ var list = SepList.initEmpty();
+ list.set(1);
+ list.set(8);
+ list.set(9);
+ list.set(10);
+ list.set(11);
+ list.set(12);
+ break :sep list;
+ },
+ };
+
+ {
+ const v = p.next().?;
+ try testing.expect(v == .unset);
+ }
+ {
+ const v = p.next().?;
+ try testing.expect(v == .underline);
+ try testing.expectEqual(Attribute.Underline.curly, v.underline);
+ }
+ {
+ const v = p.next().?;
+ try testing.expect(v == .direct_color_fg);
+ try testing.expectEqual(@as(u8, 175), v.direct_color_fg.r);
+ try testing.expectEqual(@as(u8, 175), v.direct_color_fg.g);
+ try testing.expectEqual(@as(u8, 215), v.direct_color_fg.b);
+ }
+ {
+ const v = p.next().?;
+ try testing.expect(v == .underline_color);
+ try testing.expectEqual(@as(u8, 190), v.underline_color.r);
+ try testing.expectEqual(@as(u8, 80), v.underline_color.g);
+ try testing.expectEqual(@as(u8, 70), v.underline_color.b);
+ }
+
+ //try testing.expect(p.next() == null);
+}
diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig
index 5657d63f43..eb5ab2c656 100644
--- a/src/terminal/stream.zig
+++ b/src/terminal/stream.zig
@@ -253,15 +253,11 @@ pub fn Stream(comptime Handler: type) type {
// A parameter separator:
':', ';' => if (self.parser.params_idx < 16) {
self.parser.params[self.parser.params_idx] = self.parser.param_acc;
+ if (c == ':') self.parser.params_sep.set(self.parser.params_idx);
self.parser.params_idx += 1;
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
-
- // Keep track of separator state.
- const sep: Parser.ParamSepState = @enumFromInt(c);
- if (self.parser.params_idx == 1) self.parser.params_sep = sep;
- if (self.parser.params_sep != sep) self.parser.params_sep = .mixed;
},
// Explicitly ignored:
0x7F => {},
@@ -937,7 +933,10 @@ pub fn Stream(comptime Handler: type) type {
'm' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setAttribute")) {
// log.info("parse SGR params={any}", .{action.params});
- var p: sgr.Parser = .{ .params = input.params, .colon = input.sep == .colon };
+ var p: sgr.Parser = .{
+ .params = input.params,
+ .params_sep = input.params_sep,
+ };
while (p.next()) |attr| {
// log.info("SGR attribute: {}", .{attr});
try self.handler.setAttribute(attr);
diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig
index 1a3b8cad00..caef2229db 100644
--- a/src/termio/Exec.zig
+++ b/src/termio/Exec.zig
@@ -179,8 +179,17 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void {
// Quit our read thread after exiting the subprocess so that
// we don't get stuck waiting for data to stop flowing if it is
// a particularly noisy process.
- _ = posix.write(exec.read_thread_pipe, "x") catch |err|
- log.warn("error writing to read thread quit pipe err={}", .{err});
+ _ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) {
+ // BrokenPipe means that our read thread is closed already,
+ // which is completely fine since that is what we were trying
+ // to achieve.
+ error.BrokenPipe => {},
+
+ else => log.warn(
+ "error writing to read thread quit pipe err={}",
+ .{err},
+ ),
+ };
if (comptime builtin.os.tag == .windows) {
// Interrupt the blocking read so the thread can see the quit message
@@ -673,6 +682,7 @@ pub const ThreadData = struct {
pub const Config = struct {
command: ?[]const u8 = null,
+ env: ?EnvMap = null,
shell_integration: configpkg.Config.ShellIntegration = .detect,
shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
working_directory: ?[]const u8 = null,
@@ -694,7 +704,7 @@ const Subprocess = struct {
arena: std.heap.ArenaAllocator,
cwd: ?[]const u8,
- env: EnvMap,
+ env: ?EnvMap,
args: [][]const u8,
grid_size: renderer.GridSize,
screen_size: renderer.ScreenSize,
@@ -712,19 +722,10 @@ const Subprocess = struct {
errdefer arena.deinit();
const alloc = arena.allocator();
- // Set our env vars. For Flatpak builds running in Flatpak we don't
- // inherit our environment because the login shell on the host side
- // will get it.
- var env = env: {
- if (comptime build_config.flatpak) {
- if (internal_os.isFlatpak()) {
- break :env std.process.EnvMap.init(alloc);
- }
- }
-
- break :env try std.process.getEnvMap(alloc);
- };
- errdefer env.deinit();
+ // Get our env. If a default env isn't provided by the caller
+ // then we get it ourselves.
+ var env = cfg.env orelse try internal_os.getEnvMap(alloc);
+ errdefer if (cfg.env == null) env.deinit();
// If we have a resources dir then set our env var
if (cfg.resources_dir) |dir| {
@@ -838,35 +839,11 @@ const Subprocess = struct {
try env.put("TERM_PROGRAM", "ghostty");
try env.put("TERM_PROGRAM_VERSION", build_config.version_string);
- // When embedding in macOS and running via XCode, XCode injects
- // a bunch of things that break our shell process. We remove those.
- if (comptime builtin.target.isDarwin() and build_config.artifact == .lib) {
- if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) {
- env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS");
- env.remove("__XPC_DYLD_LIBRARY_PATH");
- env.remove("DYLD_FRAMEWORK_PATH");
- env.remove("DYLD_INSERT_LIBRARIES");
- env.remove("DYLD_LIBRARY_PATH");
- env.remove("LD_LIBRARY_PATH");
- env.remove("SECURITYSESSIONID");
- env.remove("XPC_SERVICE_NAME");
- }
-
- // Remove this so that running `ghostty` within Ghostty works.
- env.remove("GHOSTTY_MAC_APP");
- }
-
// VTE_VERSION is set by gnome-terminal and other VTE-based terminals.
// We don't want our child processes to think we're running under VTE.
+ // This is not apprt-specific, so we do it here.
env.remove("VTE_VERSION");
- // Don't leak these GTK environment variables to child processes.
- if (comptime build_config.app_runtime == .gtk) {
- env.remove("GDK_DEBUG");
- env.remove("GDK_DISABLE");
- env.remove("GSK_RENDERER");
- }
-
// Setup our shell integration, if we can.
const integrated_shell: ?shell_integration.Shell, const shell_command: []const u8 = shell: {
const default_shell_command = cfg.command orelse switch (builtin.os.tag) {
@@ -875,7 +852,11 @@ const Subprocess = struct {
};
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
- .none => break :shell .{ null, default_shell_command },
+ .none => {
+ // Even if shell integration is none, we still want to set up the feature env vars
+ try shell_integration.setupFeatures(&env, cfg.shell_integration_features);
+ break :shell .{ null, default_shell_command };
+ },
.detect => null,
.bash => .bash,
.elvish => .elvish,
@@ -971,12 +952,12 @@ const Subprocess = struct {
// which we may not want. If we specify "-l" then we can avoid
// this behavior but now the shell isn't a login shell.
//
- // There is another issue: `login(1)` only checks for ".hushlogin"
- // in the working directory. This means that if we specify "-l"
- // then we won't get hushlogin honored if its in the home
- // directory (which is standard). To get around this, we
- // check for hushlogin ourselves and if present specify the
- // "-q" flag to login(1).
+ // There is another issue: `login(1)` on macOS 14.3 and earlier
+ // checked for ".hushlogin" in the working directory. This means
+ // that if we specify "-l" then we won't get hushlogin honored
+ // if its in the home directory (which is standard). To get
+ // around this, we check for hushlogin ourselves and if present
+ // specify the "-q" flag to login(1).
//
// So to get all the behaviors we want, we specify "-l" but
// execute "bash" (which is built-in to macOS). We then use
@@ -1073,6 +1054,7 @@ const Subprocess = struct {
pub fn deinit(self: *Subprocess) void {
self.stop();
if (self.pty) |*pty| pty.deinit();
+ if (self.env) |*env| env.deinit();
self.arena.deinit();
self.* = undefined;
}
@@ -1094,6 +1076,10 @@ const Subprocess = struct {
});
self.pty = pty;
errdefer {
+ if (comptime builtin.os.tag != .windows) {
+ _ = posix.close(pty.slave);
+ }
+
pty.deinit();
self.pty = null;
}
@@ -1151,7 +1137,7 @@ const Subprocess = struct {
var cmd: Command = .{
.path = self.args[0],
.args = self.args,
- .env = &self.env,
+ .env = if (self.env) |*env| env else null,
.cwd = cwd,
.stdin = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
.stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
@@ -1178,6 +1164,19 @@ const Subprocess = struct {
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
}
+ if (comptime builtin.os.tag != .windows) {
+ // Once our subcommand is started we can close the slave
+ // side. This prevents the slave fd from being leaked to
+ // future children.
+ _ = posix.close(pty.slave);
+ }
+
+ // Successful start we can clear out some memory.
+ if (self.env) |*env| {
+ env.deinit();
+ self.env = null;
+ }
+
self.command = cmd;
return switch (builtin.os.tag) {
.windows => .{
@@ -1452,6 +1451,13 @@ pub const ReadThread = struct {
log.info("read thread got quit signal", .{});
return;
}
+
+ // If our pty fd is closed, then we're also done with our
+ // read thread.
+ if (pollfds[0].revents & posix.POLL.HUP != 0) {
+ log.info("pty fd closed, read thread exiting", .{});
+ return;
+ }
}
}
diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig
index 8cd2a92ae2..423e2f5186 100644
--- a/src/termio/shell_integration.zig
+++ b/src/termio/shell_integration.zig
@@ -58,67 +58,73 @@ pub fn setup(
break :exe std.fs.path.basename(command[0..idx]);
};
- const result: ShellIntegration = shell: {
- if (std.mem.eql(u8, "bash", exe)) {
- // Apple distributes their own patched version of Bash 3.2
- // on macOS that disables the ENV-based POSIX startup path.
- // This means we're unable to perform our automatic shell
- // integration sequence in this specific environment.
- //
- // If we're running "/bin/bash" on Darwin, we can assume
- // we're using Apple's Bash because /bin is non-writable
- // on modern macOS due to System Integrity Protection.
- if (comptime builtin.target.isDarwin()) {
- if (std.mem.eql(u8, "/bin/bash", command)) {
- return null;
- }
- }
+ const result = try setupShell(alloc_arena, resource_dir, command, env, exe);
- const new_command = try setupBash(
- alloc_arena,
- command,
- resource_dir,
- env,
- ) orelse return null;
- break :shell .{
- .shell = .bash,
- .command = new_command,
- };
- }
+ // Setup our feature env vars
+ try setupFeatures(env, features);
- if (std.mem.eql(u8, "elvish", exe)) {
- try setupXdgDataDirs(alloc_arena, resource_dir, env);
- break :shell .{
- .shell = .elvish,
- .command = try alloc_arena.dupe(u8, command),
- };
- }
+ return result;
+}
- if (std.mem.eql(u8, "fish", exe)) {
- try setupXdgDataDirs(alloc_arena, resource_dir, env);
- break :shell .{
- .shell = .fish,
- .command = try alloc_arena.dupe(u8, command),
- };
+fn setupShell(
+ alloc_arena: Allocator,
+ resource_dir: []const u8,
+ command: []const u8,
+ env: *EnvMap,
+ exe: []const u8,
+) !?ShellIntegration {
+ if (std.mem.eql(u8, "bash", exe)) {
+ // Apple distributes their own patched version of Bash 3.2
+ // on macOS that disables the ENV-based POSIX startup path.
+ // This means we're unable to perform our automatic shell
+ // integration sequence in this specific environment.
+ //
+ // If we're running "/bin/bash" on Darwin, we can assume
+ // we're using Apple's Bash because /bin is non-writable
+ // on modern macOS due to System Integrity Protection.
+ if (comptime builtin.target.isDarwin()) {
+ if (std.mem.eql(u8, "/bin/bash", command)) {
+ return null;
+ }
}
- if (std.mem.eql(u8, "zsh", exe)) {
- try setupZsh(resource_dir, env);
- break :shell .{
- .shell = .zsh,
- .command = try alloc_arena.dupe(u8, command),
- };
- }
+ const new_command = try setupBash(
+ alloc_arena,
+ command,
+ resource_dir,
+ env,
+ ) orelse return null;
+ return .{
+ .shell = .bash,
+ .command = new_command,
+ };
+ }
- return null;
- };
+ if (std.mem.eql(u8, "elvish", exe)) {
+ try setupXdgDataDirs(alloc_arena, resource_dir, env);
+ return .{
+ .shell = .elvish,
+ .command = try alloc_arena.dupe(u8, command),
+ };
+ }
- // Setup our feature env vars
- if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1");
- if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1");
- if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1");
+ if (std.mem.eql(u8, "fish", exe)) {
+ try setupXdgDataDirs(alloc_arena, resource_dir, env);
+ return .{
+ .shell = .fish,
+ .command = try alloc_arena.dupe(u8, command),
+ };
+ }
- return result;
+ if (std.mem.eql(u8, "zsh", exe)) {
+ try setupZsh(resource_dir, env);
+ return .{
+ .shell = .zsh,
+ .command = try alloc_arena.dupe(u8, command),
+ };
+ }
+
+ return null;
}
test "force shell" {
@@ -138,6 +144,58 @@ test "force shell" {
}
}
+/// Setup shell integration feature environment variables without
+/// performing full shell integration setup.
+pub fn setupFeatures(
+ env: *EnvMap,
+ features: config.ShellIntegrationFeatures,
+) !void {
+ if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1");
+ if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1");
+ if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1");
+}
+
+test "setup features" {
+ const testing = std.testing;
+
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Test: all features enabled (no environment variables should be set)
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true });
+ try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") == null);
+ try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null);
+ try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE") == null);
+ }
+
+ // Test: all features disabled
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false });
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?);
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO").?);
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?);
+ }
+
+ // Test: mixed features
+ {
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false });
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?);
+ try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null);
+ try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?);
+ }
+}
+
/// Setup the bash automatic shell integration. This works by
/// starting bash in POSIX mode and using the ENV environment
/// variable to load our bash integration script. This prevents
@@ -145,8 +203,6 @@ test "force shell" {
/// our script's responsibility (along with disabling POSIX
/// mode).
///
-/// This approach requires bash version 4 or later.
-///
/// This returns a new (allocated) shell command string that
/// enables the integration or null if integration failed.
fn setupBash(
@@ -188,12 +244,6 @@ fn setupBash(
// Unsupported options:
// -c -c is always non-interactive
// --posix POSIX mode (a la /bin/sh)
- //
- // Some additional cases we don't yet cover:
- //
- // - If additional file arguments are provided (after a `-` or `--` flag),
- // and the `i` shell option isn't being explicitly set, we can assume a
- // non-interactive shell session and skip loading our shell integration.
var rcfile: ?[]const u8 = null;
while (iter.next()) |arg| {
if (std.mem.eql(u8, arg, "--posix")) {
@@ -210,6 +260,14 @@ fn setupBash(
return null;
}
try args.append(arg);
+ } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
+ // All remaining arguments should be passed directly to the shell
+ // command. We shouldn't perform any further option processing.
+ try args.append(arg);
+ while (iter.next()) |remaining_arg| {
+ try args.append(remaining_arg);
+ }
+ break;
} else {
try args.append(arg);
}
@@ -372,6 +430,30 @@ test "bash: HISTFILE" {
}
}
+test "bash: additional arguments" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var env = EnvMap.init(alloc);
+ defer env.deinit();
+
+ // "-" argument separator
+ {
+ const command = try setupBash(alloc, "bash - --arg file1 file2", ".", &env);
+ defer if (command) |c| alloc.free(c);
+
+ try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?);
+ }
+
+ // "--" argument separator
+ {
+ const command = try setupBash(alloc, "bash -- --arg file1 file2", ".", &env);
+ defer if (command) |c| alloc.free(c);
+
+ try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?);
+ }
+}
+
/// Setup automatic shell integration for shells that include
/// their modules from paths in `XDG_DATA_DIRS` env variable.
///
diff --git a/src/unicode/props.zig b/src/unicode/props.zig
index d77bf4c8ae..8c7621b795 100644
--- a/src/unicode/props.zig
+++ b/src/unicode/props.zig
@@ -131,7 +131,9 @@ pub fn get(cp: u21) Properties {
/// Runnable binary to generate the lookup tables and output to stdout.
pub fn main() !void {
- const alloc = std.heap.c_allocator;
+ var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena_state.deinit();
+ const alloc = arena_state.allocator();
const gen: lut.Generator(
Properties,