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,