diff --git a/.agents/commands/review-branch b/.agents/commands/review-branch new file mode 100755 index 000000000..edd8bcbd8 --- /dev/null +++ b/.agents/commands/review-branch @@ -0,0 +1,75 @@ +#!/usr/bin/env nu + +# A command to review the changes made in the current Git branch. +# +# IMPORTANT: This command is prompted to NOT write any code and to ONLY +# produce a review summary. You should still be vigilant when running this +# but that is the expected behavior. +# +# The optional `` parameter can be an issue number, PR number, +# or a full GitHub URL to provide additional context. +def main [ + issue?: any, # Optional GitHub issue/PR number or URL for context +] { + let issueContext = if $issue != null { + let data = gh issue view $issue --json author,title,number,body,comments | from json + let comments = if ($data.comments? != null) { + $data.comments | each { |comment| + let author = if ($comment.author?.login? != null) { $comment.author.login } else { "unknown" } + $" +### Comment by ($author) +($comment.body) +" | str trim + } | str join "\n\n" + } else { + "" + } + + $" +## Source Issue: ($data.title) \(#($data.number)\) + +### Description +($data.body) + +### Comments +($comments) +" + } else { + "" + } + + $" +# Branch Review + +Inspect the changes made in this Git branch. Identify any possible issues +and suggest improvements. Do not write code. Explain the problems clearly +and propose a brief plan for addressing them. +($issueContext) +## Your Tasks + +You are an experienced software developer with expertise in code review. + +Review the change history between the current branch and its +base branch. Analyze all relevant code for possible issues, including but +not limited to: + +- Code quality and readability +- Code style that matches or mimics the rest of the codebase +- Potential bugs or logical errors +- Edge cases that may not be handled +- Performance considerations +- Security vulnerabilities +- Backwards compatibility \(if applicable\) +- Test coverage and effectiveness + +For test coverage, consider if the changes are in an area of the codebase +that is testable. If so, check if there are appropriate tests added or +modified. Consider if the code itself should be modified to be more +testable. + +Think deeply about the implications of the changes here and proposed. +Consult the oracle if you have access to it. + +**ONLY CREATE A SUMMARY. DO NOT WRITE ANY CODE.** +" | str trim +} diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..7c4256e0e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,50 @@ +on: + workflow_dispatch: + inputs: + source-run-id: + description: run id of the workflow that generated the artifact + required: true + type: string + source-artifact-id: + description: source tarball built during build-dist + required: true + type: string + +name: Flatpak + +jobs: + build: + if: github.repository == 'ghostty-org/ghostty' + name: "Flatpak" + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 + options: --privileged + strategy: + fail-fast: false + matrix: + variant: + - arch: x86_64 + runner: namespace-profile-ghostty-md + - arch: aarch64 + runner: namespace-profile-ghostty-md-arm64 + runs-on: ${{ matrix.variant.runner }} + steps: + - name: Download Source Tarball Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + run-id: ${{ inputs.source-run-id }} + artifact-ids: ${{ inputs.source-artifact-id }} + github-token: ${{ github.token }} + + - name: Extract tarball + run: | + mkdir dist + tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz + + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + with: + bundle: com.mitchellh.ghostty + manifest-path: dist/flatpak/com.mitchellh.ghostty.yml + cache-key: flatpak-builder-${{ github.sha }} + arch: ${{ matrix.variant.arch }} + verbose: true diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..748965513 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos path: |- @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -309,7 +309,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sparkle path: |- @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 641bbcca6..6fc7e0fb4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..30f34120a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - build-linux-libghostty - build-nix - build-macos - - build-macos-matrix + - build-macos-freetype - build-snap - build-windows - test @@ -44,7 +44,7 @@ jobs: - test-debian-13 - valgrind - zig-fmt - - flatpak + steps: - id: status name: Determine status @@ -397,7 +397,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -421,6 +421,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + trigger-flatpak: + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm + needs: [build-dist, build-flatpak] + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Trigger Flatpak workflow + run: | + gh workflow run \ + flatpak.yml \ + --ref ${{ github.ref_name || 'main' }} \ + --field source-run-id=${{ github.run_id }} \ + --field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test @@ -464,7 +482,7 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-matrix: + build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test steps: @@ -493,18 +511,10 @@ jobs: - name: Test All run: | nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape build-windows: runs-on: windows-2022 @@ -1075,7 +1085,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball @@ -1092,32 +1102,6 @@ jobs: build-args: | DISTRO_VERSION=13 - flatpak: - if: github.repository == 'ghostty-org/ghostty' - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 - options: --privileged - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: namespace-profile-ghostty-md - - arch: aarch64 - runner: namespace-profile-ghostty-md-arm64 - runs-on: ${{ matrix.variant.runner }} - needs: test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 - with: - bundle: com.mitchellh.ghostty - manifest-path: flatpak/com.mitchellh.ghostty.yml - cache-key: flatpak-builder-${{ github.sha }} - arch: ${{ matrix.variant.arch }} - verbose: true - valgrind: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-lg diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index dc3ebb2b6..4ca4d2901 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -37,16 +37,33 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch + - name: Download colorschemes + id: download env: GH_TOKEN: ${{ github.token }} run: | # Get the latest release from iTerm2-Color-Schemes RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest) TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name') - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" + FILENAME="ghostty-themes-${TAG_NAME}.tgz" + mkdir -p upload + curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "filename=$FILENAME" >> $GITHUB_OUTPUT + + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }} + r2-bucket: ghostty-deps + source-dir: upload + destination-dir: ./ + + - name: Run zig fetch + run: | + nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}" - name: Update zig cache hash run: | @@ -75,5 +92,5 @@ jobs: build.zig.zon.json flatpak/zig-packages.json body: | - Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }} + Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }} labels: dependencies diff --git a/.gitignore b/.gitignore index e451b171a..e521f8851 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ zig-cache/ .zig-cache/ zig-out/ /result* +/.nixos-test-history example/*.wasm test/ghostty test/cases/**/*.actual.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4285f42f..cbb6927d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,12 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > [!IMPORTANT] > -> If you are using **any kind of AI assistance** to contribute to Ghostty, -> it must be disclosed in the pull request. +> The Ghostty project allows AI-**assisted** _code contributions_, which +> must be properly disclosed in the pull request. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -As a small exception, trivial tab-completion doesn't need to be disclosed, -so long as it is limited to single keywords or short phrases. The submitter must have also tested the pull request on all impacted platforms, and it's **highly discouraged** to code for an unfamiliar platform @@ -32,10 +30,49 @@ with AI assistance alone: if you only have a macOS machine, do **not** ask AI to write the equivalent GTK code, and vice versa — someone else with more expertise will eventually get to it and do it for you. -Even though using AI to generate responses on a PR is allowed when properly -disclosed, **we do not encourage you to do so**. Often, the positive impact -of genuine, responsive human interaction more than makes up for any language -barrier. ❤️ +> [!WARNING] +> **Note that AI _assistance_ does not equal AI _generation_**. We require +> a significant amount of human accountability, involvement and interaction +> even within AI-assisted contributions. Contributors are required to be able +> to understand the AI-assisted output, reason with it and answer critical +> questions about it. Should a PR see no visible human accountability and +> involvement, or it is so broken that it requires significant rework to be +> acceptable, **we reserve the right to close it without hesitation**. + +**In addition, we currently restrict AI assistance to code changes only.** +No AI-generated media, e.g. artwork, icons, videos and other assets is +allowed, as it goes against the methodology and ethos behind Ghostty. +While AI-assisted code can help with productive prototyping, creative +inspiration and even automated bugfinding, we have currently found zero +benefit to AI-generated assets. Instead, we are far more interested and +invested in funding professional work done by human designers and artists. +If you intend to submit AI-generated assets to Ghostty, sorry, +we are not interested. + +Likewise, all community interactions, including all comments on issues and +discussions and all PR titles and descriptions **must be composed by a human**. +Community moderators and Ghostty maintainers reserve the right to mark +AI-generated responses as spam or disruptive content, and ban users who have +been repeatedly caught relying entirely on LLMs during interactions. + +> [!NOTE] +> If your English isn't the best and you are currently relying on an LLM to +> translate your responses, don't fret — usually we maintainers will be able +> to understand your messages well enough. We'd like to encourage real humans +> to interact with each other more, and the positive impact of genuine, +> responsive yet imperfect human interaction more than makes up for any +> language barrier. +> +> Please write your responses yourself, to the best of your ability. +> If you do feel the need to polish your sentences, however, please use +> dedicated translation software rather than an LLM. +> +> We greatly appreciate it. Thank you. ❤️ + +Minor exceptions to this policy include trivial AI-generated tab completion +functionality, as it usually does not impact the quality of the code and +do not need to be disclosed, and commit titles and messages, which are often +generated by AI coding agents. An example disclosure: @@ -60,13 +97,6 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect a fairly high level of accountability -and responsibility from contributors, and expect them to understand the code -that is produced and be able to answer critical questions about it. It -isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable, and we **reserve the right to close -these PRs without hesitation**. - Please be respectful to maintainers and disclose AI assistance. ## Quick Guide @@ -202,3 +232,266 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +# Developer Guide + +> [!NOTE] +> +> **The remainder of this file is dedicated to developers actively +> working on Ghostty.** If you're a user reporting an issue, you can +> ignore the rest of this document. + +## Including and Updating Translations + +See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. + +## Checking for Memory Leaks + +While Zig does an amazing job of finding and preventing memory leaks, +Ghostty uses many third-party libraries that are written in C. Improper usage +of those libraries or bugs in those libraries can cause memory leaks that +Zig cannot detect by itself. + +### On Linux + +On Linux the recommended tool to check for memory leaks is Valgrind. The +recommended way to run Valgrind is via `zig build`: + +```sh +zig build run-valgrind +``` + +This builds a Ghostty executable with Valgrind support and runs Valgrind +with the proper flags to ensure we're suppressing known false positives. + +You can combine the same build args with `run-valgrind` that you can with +`run`, such as specifying additional configurations after a trailing `--`. + +## Input Stack Testing + +The input stack is the part of the codebase that starts with a +key event and ends with text encoding being sent to the pty (it +does not include _rendering_ the text, which is part of the +font or rendering stack). + +If you modify any part of the input stack, you must manually verify +all the following input cases work properly. We unfortunately do +not automate this in any way, but if we can do that one day that'd +save a LOT of grief and time. + +Note: this list may not be exhaustive, I'm still working on it. + +### Linux IME + +IME (Input Method Editors) are a common source of bugs in the input stack, +especially on Linux since there are multiple different IME systems +interacting with different windowing systems and application frameworks +all written by different organizations. + +The following matrix should be tested to ensure that all IME input works +properly: + +1. Wayland, X11 +2. ibus, fcitx, none +3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex +4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors) + +> [!NOTE] +> +> This is a **work in progress**. I'm still working on this list and it +> is not complete. As I find more test cases, I will add them here. + +#### Dead Key Input + +Set your keyboard layout to "Spanish" (or another layout that uses dead keys). + +1. Launch Ghostty +2. Press `'` +3. Press `a` +4. Verify that `á` is displayed + +Note that the dead key may or may not show a preedit state visually. +For ibus and fcitx it does but for the "none" case it does not. Importantly, +the text should be correct when it is sent to the pty. + +We should also test canceling dead key input: + +1. Launch Ghostty +2. Press `'` +3. Press escape +4. Press `a` +5. Verify that `a` is displayed (no diacritic) + +#### CJK Input + +Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The +exact layout doesn't matter. + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Enter` +5. Verify that `こん` is displayed in the terminal. + +We should also test switching input methods while preedit is active, which +should commit the text: + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Ctrl+Shift` to switch to another layout (any) +5. Verify that `こん` is displayed in the terminal as committed text. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### 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. They should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. + +## Nix VM Integration Tests + +Several Nix VM tests are provided by the project for testing Ghostty in a "live" +environment rather than just unit tests. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#check...driver`. `` should be + `x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux + VM, not a macOS one). `` should be one of the tests defined in + `nix/tests.nix`. The test will build and then launch. Depending on the speed + of your system, this can take a while. Eventually though the test should + complete. Hopefully successfully, but if not error messages should be printed + out that can be used to diagnose the issue. +3. To run _all_ of the tests, run `nix flake check`. + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a test. + +### Interactively Running Test VMs + +To run a test interactively, run `nix run +.#check...driverInteractive`. This will load a Python console +that can be used to manage the test VMs. In this console run `start_all()` to +start the VM(s). The VMs should boot up and a window should appear showing the +VM's console. + +For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos) + +### SSH Access to Test VMs + +Some test VMs are configured to allow outside SSH access for debugging. To +access the VM, use a command like the following: + +``` +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1 +``` + +The SSH options are important because the SSH host keys will be regenerated +every time the test is started. Without them, your personal SSH known hosts file +will become difficult to manage. The port that is needed to access the VM may +change depending on the test. + +None of the users in the VM have passwords so do not expose these VMs to the Internet. diff --git a/HACKING.md b/HACKING.md index 0a4bbef20..bde50ec99 100644 --- a/HACKING.md +++ b/HACKING.md @@ -93,6 +93,36 @@ produced. > may ask you to fix it and close the issue. It isn't a maintainers job to > review a PR so broken that it requires significant rework to be acceptable. +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + ## Linting ### Prettier diff --git a/build.zig b/build.zig index 472c3957a..fa68b91b4 100644 --- a/build.zig +++ b/build.zig @@ -318,8 +318,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -/// Marker used by Config.zig to detect if ghostty is the build root. -/// This avoids running logic such as Git tag checking when Ghostty -/// is used as a dependency. -pub const _ghostty_build_root = true; diff --git a/build.zig.zon b/build.zig.zon index 191ae7fa9..271428778 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,13 +15,13 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ @@ -50,14 +50,14 @@ }, .zf = .{ // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak new file mode 100644 index 000000000..191ae7fa9 --- /dev/null +++ b/build.zig.zon.bak @@ -0,0 +1,124 @@ +.{ + .name = .ghostty, + .version = "1.3.0-dev", + .paths = .{""}, + .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + // Zig libs + + .libxev = .{ + // mitchellh/libxev + .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", + .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", + .lazy = true, + }, + .vaxis = .{ + // rockorager/libvaxis + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + .lazy = true, + }, + .z2d = .{ + // vancluever/z2d + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .lazy = true, + }, + .zig_objc = .{ + // mitchellh/zig-objc + .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", + .lazy = true, + }, + .zig_js = .{ + // mitchellh/zig-js + .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", + .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", + .lazy = true, + }, + .uucode = .{ + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + }, + .zig_wayland = .{ + // codeberg ifreund/zig-wayland + .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", + .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", + .lazy = true, + }, + .zf = .{ + // natecraddock/zf + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + .lazy = true, + }, + .gobject = .{ + // https://github.com/ghostty-org/zig-gobject based on zig_gobject + // Temporary until we generate them at build time automatically. + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + .lazy = true, + }, + + // C libs + .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, + .freetype = .{ .path = "./pkg/freetype", .lazy = true }, + .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, + .highway = .{ .path = "./pkg/highway", .lazy = true }, + .libintl = .{ .path = "./pkg/libintl", .lazy = true }, + .libpng = .{ .path = "./pkg/libpng", .lazy = true }, + .macos = .{ .path = "./pkg/macos", .lazy = true }, + .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, + .opengl = .{ .path = "./pkg/opengl", .lazy = true }, + .sentry = .{ .path = "./pkg/sentry", .lazy = true }, + .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, + .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, + .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, + .zlib = .{ .path = "./pkg/zlib", .lazy = true }, + + // Shader translation + .glslang = .{ .path = "./pkg/glslang", .lazy = true }, + .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, + + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", + .lazy = true, + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .lazy = true, + }, + .plasma_wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", + .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", + .lazy = true, + }, + + // Fonts + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + .lazy = true, + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, + }, + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .lazy = true, + }, + }, +} diff --git a/build.zig.zon.json b/build.zig.zon.json index e4171834d..c9a64ca5f 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -26,7 +26,7 @@ }, "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -116,12 +116,12 @@ }, "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { @@ -141,12 +141,12 @@ }, "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c0f923145..43a8efe46 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -126,7 +126,7 @@ in name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst"; hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } @@ -270,7 +270,7 @@ in name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } @@ -278,7 +278,7 @@ in name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } @@ -310,7 +310,7 @@ in name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } @@ -318,7 +318,7 @@ in name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index ceeb3aa3d..24a2978d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,6 +6,7 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz @@ -19,17 +20,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz +https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz -https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz -https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flake.lock b/flake.lock index 0150f7b84..a80c2f8ae 100644 --- a/flake.lock +++ b/flake.lock @@ -34,6 +34,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755776884, + "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1763191728, @@ -51,6 +72,7 @@ "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" diff --git a/flake.nix b/flake.nix index 18ca3ac18..d70f23513 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,13 @@ nixpkgs.follows = "nixpkgs"; }; }; + + home-manager = { + url = "github:nix-community/home-manager?ref=release-25.05"; + inputs = { + nixpkgs.follows = "nixpkgs"; + }; + }; }; outputs = { @@ -40,6 +47,7 @@ nixpkgs, zig, zon2nix, + home-manager, ... }: builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( @@ -47,7 +55,7 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.15.2"; wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; @@ -79,6 +87,10 @@ formatter.${system} = pkgs.alejandra; + checks.${system} = import ./nix/tests.nix { + inherit home-manager nixpkgs self system; + }; + apps.${system} = let runVM = ( module: let @@ -95,6 +107,9 @@ in { type = "app"; program = "${program}"; + meta = { + description = "start a vm from ${toString module}"; + }; } ); in { @@ -120,11 +135,6 @@ ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; - create-vm = import ./nix/vm/create.nix; - create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; - create-gnome-vm = import ./nix/vm/create-gnome.nix; - create-plasma6-vm = import ./nix/vm/create-plasma6.nix; - create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a6d431c8e..21f79ec04 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,7 +31,7 @@ }, { "type": "archive", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, @@ -139,13 +139,13 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, @@ -169,13 +169,13 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..47db34e71 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -512,6 +512,12 @@ typedef enum { GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, @@ -573,6 +579,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -791,9 +803,11 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, @@ -837,6 +851,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { @@ -845,6 +860,7 @@ typedef union { ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; @@ -874,6 +890,7 @@ typedef union { ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index eb5d706c3..562166c87 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, Features/QuickTerminal/QuickTerminalPosition.swift, + Features/QuickTerminal/QuickTerminalRestorableState.swift, Features/QuickTerminal/QuickTerminalScreen.swift, Features/QuickTerminal/QuickTerminalScreenStateCache.swift, Features/QuickTerminal/QuickTerminalSize.swift, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8baee3d89..1697f7438 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -98,11 +99,35 @@ class AppDelegate: NSObject, /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() + /// The current state of the quick terminal. + private var quickTerminalControllerState: QuickTerminalState = .uninitialized + /// Our quick terminal. This starts out uninitialized and only initializes if used. - private(set) lazy var quickController = QuickTerminalController( - ghostty, - position: derivedConfig.quickTerminalPosition - ) + var quickController: QuickTerminalController { + switch quickTerminalControllerState { + case .initialized(let controller): + return controller + + case .pendingRestore(let state): + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + baseConfig: state.baseConfig, + restorationState: state + ) + quickTerminalControllerState = .initialized(controller) + return controller + + case .uninitialized: + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + restorationState: nil + ) + quickTerminalControllerState = .initialized(controller) + return controller + } + } /// Manages updates let updateController = UpdateController() @@ -544,6 +569,7 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") @@ -994,10 +1020,31 @@ class AppDelegate: NSObject, func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { Self.logger.debug("application will save window state") + + guard ghostty.config.windowSaveState != "never" else { return } + + // Encode our quick terminal state if we have it. + switch quickTerminalControllerState { + case .initialized(let controller) where controller.restorable: + let data = QuickTerminalRestorableState(from: controller) + data.encode(with: coder) + + case .pendingRestore(let state): + state.encode(with: coder) + + default: + break + } } func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { Self.logger.debug("application will restore window state") + + // Decode our quick terminal state. + if ghostty.config.windowSaveState != "never", + let state = QuickTerminalRestorableState(coder: coder) { + quickTerminalControllerState = .pendingRestore(state) + } } //MARK: - UNUserNotificationCenterDelegate @@ -1271,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation { } } +/// Represents the state of the quick terminal controller. +private enum QuickTerminalState { + /// Controller has not been initialized and has no pending restoration state. + case uninitialized + /// Restoration state is pending; controller will use this when first accessed. + case pendingRestore(QuickTerminalRestorableState) + /// Controller has been initialized. + case initialized(QuickTerminalController) +} + @globalActor fileprivate actor AppIconActor: GlobalActor { static let shared = AppIconActor() diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index d009b9c62..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -47,6 +47,7 @@ + @@ -328,6 +329,12 @@ + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 201289736..8a642034f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController { private var previousActiveSpace: CGSSpace? = nil /// Cache for per-screen window state. - private let screenStateCache = QuickTerminalScreenStateCache() + let screenStateCache: QuickTerminalScreenStateCache /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + + /// This is set to false by init if the window managed by this controller should not be restorable. + /// For example, terminals executing custom scripts are not restorable. + let restorable: Bool + private var restorationState: QuickTerminalRestorableState? + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: SplitTree? = nil + restorationState: QuickTerminalRestorableState? = nil, ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - + // The window we manage is not restorable if we've specified a command + // to execute. We do this because the restored window is meaningless at the + // time of writing this: it'd just restore to a shell in the same directory + // as the script. We may want to revisit this behavior when we have scrollback + // restoration. + restorable = (base?.command ?? "") == "" + self.restorationState = restorationState + self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:]) // Important detail here: we initialize with an empty surface tree so // that we don't start a terminal process. This gets started when the // first terminal is shown in `animateIn`. @@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController { // window close so we can animate out. window.delegate = self - // The quick window is not restorable (yet!). "Yet" because in theory we can - // make this restorable, but it isn't currently implemented. + // The quick window is restored by `screenStateCache`. + // We disable this for better control window.isRestorable = false // Setup our configured appearance that we support. @@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController { // animate out. if surfaceTree.isEmpty, let ghostty_app = ghostty.app { - var config = Ghostty.SurfaceConfiguration() - config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" - - let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) - surfaceTree = SplitTree(view: view) - focusedSurface = view + if let tree = restorationState?.surfaceTree, !tree.isEmpty { + surfaceTree = tree + let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first! + focusedSurface = view + // Add a short delay to check if the correct surface is focused. + // Each SurfaceWrapper defaults its FocusedValue to itself; without this delay, + // the tree often focuses the first surface instead of the intended one. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if !view.focused { + self.focusedSurface = view + self.makeWindowKey(window) + } + } + } else { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + surfaceTree = SplitTree(view: view) + focusedSurface = view + } } // Animate the window in animateWindowIn(window: window, from: position) + // Clear the restoration state after first use + restorationState = nil } func animateOut() { @@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController { animateWindowOut(window: window, to: position) } + func saveScreenState(exitFullscreen: Bool) { + // If we are in fullscreen, then we exit fullscreen. We do this immediately so + // we have th correct window.frame for the save state below. + if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + } + guard let window else { return } + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { + screenStateCache.save(frame: window.frame, for: screen) + } + } + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } @@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { - // If we are in fullscreen, then we exit fullscreen. We do this immediately so - // we have th correct window.frame for the save state below. - if let fullscreenStyle, fullscreenStyle.isFullscreen { - fullscreenStyle.exit() - } - - // Save the current window frame before animating out. This preserves - // the user's preferred window size and position for when the quick - // terminal is reactivated with a new surface. Without this, SwiftUI - // would reset the window to its minimum content size. - if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { - screenStateCache.save(frame: window.frame, for: screen) - } + saveScreenState(exitFullscreen: true) // If we hid the dock then we unhide it. hiddenDock = nil @@ -563,7 +596,7 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance() { + override func syncAppearance() { guard let window else { return } defer { updateColorSchemeForSurfaceTree() } @@ -575,7 +608,8 @@ class QuickTerminalController: BaseTerminalController { guard window.isVisible else { return } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (self.derivedConfig.backgroundOpacity < 1) { + // Also check if the user has overridden transparency to be fully opaque. + if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift new file mode 100644 index 000000000..1fd8642d8 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift @@ -0,0 +1,26 @@ +import Cocoa + +struct QuickTerminalRestorableState: TerminalRestorable { + static var version: Int { 1 } + + let focusedSurface: String? + let surfaceTree: SplitTree + let screenStateEntries: QuickTerminalScreenStateCache.Entries + + init(from controller: QuickTerminalController) { + controller.saveScreenState(exitFullscreen: true) + self.focusedSurface = controller.focusedSurface?.id.uuidString + self.surfaceTree = controller.surfaceTree + self.screenStateEntries = controller.screenStateCache.stateByDisplay + } + + init(copy other: QuickTerminalRestorableState) { + self = other + } + + var baseConfig: Ghostty.SurfaceConfiguration? { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + return config + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift index 7dc53816c..a1c17abb9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -7,6 +7,8 @@ import Cocoa /// to restore to its previous size and position when reopened. It uses stable display UUIDs /// to survive NSScreen garbage collection and automatically prunes stale entries. class QuickTerminalScreenStateCache { + typealias Entries = [UUID: DisplayEntry] + /// The maximum number of saved screen states we retain. This is to avoid some kind of /// pathological memory growth in case we get our screen state serializing wrong. I don't /// know anyone with more than 10 screens, so let's just arbitrarily go with that. @@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache { private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 /// Keyed by display UUID to survive NSScreen garbage collection. - private var stateByDisplay: [UUID: DisplayEntry] = [:] - - init() { + private(set) var stateByDisplay: Entries = [:] + + init(stateByDisplay: Entries = [:]) { + self.stateByDisplay = stateByDisplay NotificationCenter.default.addObserver( self, selector: #selector(onScreensChanged(_:)), @@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache { } } - private struct DisplayEntry { + struct DisplayEntry: Codable { var frame: NSRect var screenSize: CGSize var scale: CGFloat diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6336f0f55..5f067c128 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController, /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig + /// Track whether background is forced opaque (true) or using config transparency (false) + var isBackgroundOpaque: Bool = false + /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] @@ -621,9 +624,14 @@ class BaseTerminalController: NSWindowController, return } - // Remove the zoomed state for this surface tree. if surfaceTree.zoomed != nil { - surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + if derivedConfig.splitPreserveZoom.contains(.navigation) { + surfaceTree = SplitTree( + root: surfaceTree.root, + zoomed: surfaceTree.root?.node(view: nextSurface)) + } else { + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) + } } // Move focus to the next surface @@ -807,6 +815,35 @@ class BaseTerminalController: NSWindowController, } } + // MARK: Appearance + + /// Toggle the background opacity between transparent and opaque states. + /// Do nothing if the configured background-opacity is >= 1 (already opaque). + /// Subclasses should override this to add platform-specific checks and sync appearance. + func toggleBackgroundOpacity() { + // Do nothing if config is already fully opaque + guard ghostty.config.backgroundOpacity < 1 else { return } + + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window, !window.styleMask.contains(.fullScreen) else { return } + + // Toggle between transparent and opaque + isBackgroundOpaque.toggle() + + // Update our appearance + syncAppearance() + } + + /// Override this to resync any appearance related properties. This will be called automatically + /// when certain window properties change that affect appearance. The list below should be updated + /// as we add new things: + /// + /// - ``toggleBackgroundOpacity`` + func syncAppearance() { + // Purposely a no-op. This lets subclasses override this and we can call + // it virtually from here. + } + // MARK: Fullscreen /// Toggle fullscreen for the given mode. @@ -867,6 +904,9 @@ class BaseTerminalController: NSWindowController, } else { updateOverlayIsVisible = defaultUpdateOverlayVisibility() } + + // Always resync our appearance + syncAppearance() } // MARK: Clipboard Confirmation @@ -1188,17 +1228,20 @@ class BaseTerminalController: NSWindowController, let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool let focusFollowsMouse: Bool + let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false self.focusFollowsMouse = false + self.splitPreserveZoom = .init() } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize self.focusFollowsMouse = config.focusFollowsMouse + self.splitPreserveZoom = config.splitPreserveZoom } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a980723ba..8a0c5f46d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -165,17 +165,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - override func fullscreenDidChange() { - super.fullscreenDidChange() - - // When our fullscreen state changes, we resync our appearance because some - // properties change when fullscreen or not. - guard let focusedSurface else { return } - - syncAppearance(focusedSurface.derivedConfig) - } - // MARK: Terminal Creation /// Returns all the available terminal controllers present in the app currently. @@ -489,6 +478,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tabWindowsHash = v self.relabelTabs() } + + override func syncAppearance() { + // When our focus changes, we update our window appearance based on the + // currently focused surface. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 425f7ffb1..fd0f4eab5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -1,10 +1,47 @@ import Cocoa +protocol TerminalRestorable: Codable { + static var selfKey: String { get } + static var versionKey: String { get } + static var version: Int { get } + init(copy other: Self) + + /// Returns a base configuration to use when restoring terminal surfaces. + /// Override this to provide custom environment variables or other configuration. + var baseConfig: Ghostty.SurfaceConfiguration? { get } +} + +extension TerminalRestorable { + static var selfKey: String { "state" } + static var versionKey: String { "version" } + + /// Default implementation returns nil (no custom base config). + var baseConfig: Ghostty.SurfaceConfiguration? { nil } + + init?(coder aDecoder: NSCoder) { + // If the version doesn't match then we can't decode. In the future we can perform + // version upgrading or something but for now we only have one version so we + // don't bother. + guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { + return nil + } + + guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { + return nil + } + + self.init(copy: v.value) + } + + func encode(with coder: NSCoder) { + coder.encode(Self.version, forKey: Self.versionKey) + coder.encode(CodableBridge(self), forKey: Self.selfKey) + } +} + /// The state stored for terminal window restoration. -class TerminalRestorableState: Codable { - static let selfKey = "state" - static let versionKey = "version" - static let version: Int = 7 +class TerminalRestorableState: TerminalRestorable { + class var version: Int { 7 } let focusedSurface: String? let surfaceTree: SplitTree @@ -20,28 +57,12 @@ class TerminalRestorableState: Codable { self.titleOverride = controller.titleOverride } - init?(coder aDecoder: NSCoder) { - // If the version doesn't match then we can't decode. In the future we can perform - // version upgrading or something but for now we only have one version so we - // don't bother. - guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { - return nil - } - - guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { - return nil - } - - self.surfaceTree = v.value.surfaceTree - self.focusedSurface = v.value.focusedSurface - self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColor = v.value.tabColor - self.titleOverride = v.value.titleOverride - } - - func encode(with coder: NSCoder) { - coder.encode(Self.version, forKey: Self.versionKey) - coder.encode(CodableBridge(self), forKey: Self.selfKey) + required init(copy other: TerminalRestorableState) { + self.surfaceTree = other.surfaceTree + self.focusedSurface = other.focusedSurface + self.effectiveFullscreenMode = other.effectiveFullscreenMode + self.tabColor = other.tabColor + self.titleOverride = other.titleOverride } } @@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } } } + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d04d7001c..4196df97f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -44,6 +44,9 @@ class TerminalWindow: NSWindow { true } + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController @@ -466,7 +469,11 @@ class TerminalWindow: NSWindow { // Window transparency only takes effect if our window is not native fullscreen. // In native fullscreen we disable transparency/opacity because the background // becomes gray and widgets show through. + // + // Also check if the user has overridden transparency to be fully opaque. + let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && + !forceOpaque && surfaceConfig.backgroundOpacity < 1 { isOpaque = false @@ -476,7 +483,15 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - if let appDelegate = NSApp.delegate as? AppDelegate { + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { + setupGlassLayer() + } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -484,6 +499,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -562,19 +582,69 @@ class TerminalWindow: NSWindow { } } +#if compiler(>=6.2) + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + // Remove existing glass effect view + removeGlassLayer() + + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() + } + + effectView.cornerRadius = derivedConfig.windowCornerRadius + effectView.tintColor = preferredBackgroundColor + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } +#endif // compiler(>=6.2) + // MARK: Config struct DerivedConfig { let title: String? + let backgroundBlur: Ghostty.Config.BackgroundBlur let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons + let macosTitlebarStyle: String + let windowCornerRadius: CGFloat init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible + self.backgroundBlur = .disabled + self.macosTitlebarStyle = "transparent" + self.windowCornerRadius = 16 } init(_ config: Ghostty.Config) { @@ -582,6 +652,18 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons + self.backgroundBlur = config.backgroundBlur + self.macosTitlebarStyle = config.macosTitlebarStyle + + // Set corner radius based on macos-titlebar-style + // Native, transparent, and hidden styles use 16pt radius + // Tabs style uses 20pt radius + switch config.macosTitlebarStyle { + case "tabs": + self.windowCornerRadius = 20 + default: + self.windowCornerRadius = 16 + } } } } @@ -708,8 +790,8 @@ extension TerminalWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ + // These selectors must all exist for it to be a tab context menu. + let requiredSelectors: Set = [ "performClose:", "performCloseOtherTabs:", "moveTabToNewWindow:", @@ -717,7 +799,7 @@ extension TerminalWindow { ] let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) + return requiredSelectors.isSubset(of: selectorNames) } private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 08d56c83d..57b889b82 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -88,7 +88,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // color of the titlebar in native fullscreen view. if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { titlebarView.wantsLayer = true - titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // For glass background styles, use a transparent titlebar to let the glass effect show through + // Only apply this for transparent and tabs titlebar styles + let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || + derivedConfig.macosTitlebarStyle == "tabs" + + titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) + ? NSColor.clear.cgColor + : preferredBackgroundColor?.cgColor } // In all cases, we have to hide the background view since this has multiple subviews diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index aff3edbc7..4e9d039d4 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -501,6 +501,9 @@ extension Ghostty { case GHOSTTY_ACTION_GOTO_SPLIT: return gotoSplit(app, target: target, direction: action.action.goto_split) + case GHOSTTY_ACTION_GOTO_WINDOW: + return gotoWindow(app, target: target, direction: action.action.goto_window) + case GHOSTTY_ACTION_RESIZE_SPLIT: resizeSplit(app, target: target, resize: action.action.resize_split) @@ -570,6 +573,9 @@ extension Ghostty { case GHOSTTY_ACTION_TOGGLE_VISIBILITY: toggleVisibility(app, target: target) + case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY: + toggleBackgroundOpacity(app, target: target) + case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) @@ -588,6 +594,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_READONLY: + setReadonly(app, target: target, v: action.action.readonly) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) @@ -1010,6 +1019,31 @@ extension Ghostty { } } + private static func setReadonly( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_readonly_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set readonly does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyDidChangeReadonly, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON, + ] + ) + + default: + assertionFailure() + } + } + private static func moveTab( _ app: ghostty_app_t, target: ghostty_target_s, @@ -1121,6 +1155,64 @@ extension Ghostty { } } + private static func gotoWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_window_e + ) -> Bool { + // Collect candidate windows: visible terminal windows that are either + // standalone or the currently selected tab in their tab group. This + // treats each native tab group as a single "window" for navigation + // purposes, since goto_tab handles per-tab navigation. + let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in + guard window.windowController is BaseTerminalController else { return false } + guard window.isVisible, !window.isMiniaturized else { return false } + // For native tabs, only include the selected tab in each group + if let group = window.tabGroup, group.selectedWindow !== window { + return false + } + return true + } + + // Need at least two windows to navigate between + guard candidates.count > 1 else { return false } + + // Find starting index from the current key/main window + let startIndex = candidates.firstIndex(where: { $0.isKeyWindow }) + ?? candidates.firstIndex(where: { $0.isMainWindow }) + ?? 0 + + let step: Int + switch direction { + case GHOSTTY_GOTO_WINDOW_NEXT: + step = 1 + case GHOSTTY_GOTO_WINDOW_PREVIOUS: + step = -1 + default: + return false + } + + // Iterate with wrap-around until we find a valid window or return to start + let count = candidates.count + var index = (startIndex + step + count) % count + + while index != startIndex { + let candidate = candidates[index] + if candidate.isVisible, !candidate.isMiniaturized { + candidate.makeKeyAndOrderFront(nil) + // Also focus the terminal surface within the window + if let controller = candidate.windowController as? BaseTerminalController, + let surface = controller.focusedSurface { + Ghostty.moveFocus(to: surface) + } + return true + } + index = (index + step + count) % count + } + + return false + } + private static func resizeSplit( _ app: ghostty_app_t, target: ghostty_target_s, @@ -1286,6 +1378,27 @@ extension Ghostty { } } + private static func toggleBackgroundOpacity( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle background opacity does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface, + let surfaceView = self.surfaceView(from: surface), + let controller = surfaceView.window?.windowController as? BaseTerminalController else { return } + + controller.toggleBackgroundOpacity() + + default: + assertionFailure() + } + } + private static func toggleSecureInput( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 2df0a8656..7ea545f7a 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -124,6 +124,14 @@ extension Ghostty { return .init(rawValue: v) } + var splitPreserveZoom: SplitPreserveZoom { + guard let config = self.config else { return .init() } + var v: CUnsignedInt = 0 + let key = "split-preserve-zoom" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + return .init(rawValue: v) + } + var initialWindow: Bool { guard let config = self.config else { return true } var v = true; @@ -402,12 +410,12 @@ extension Ghostty { return v; } - var backgroundBlurRadius: Int { - guard let config = self.config else { return 1 } - var v: Int = 0 + var backgroundBlur: BackgroundBlur { + guard let config = self.config else { return .disabled } + var v: Int16 = 0 let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return BackgroundBlur(fromCValue: v) } var unfocusedSplitOpacity: Double { @@ -626,6 +634,60 @@ extension Ghostty.Config { case download } + /// Background blur configuration that maps from the C API values. + /// Positive values represent blur radius, special negative values + /// represent macOS-specific glass effects. + enum BackgroundBlur: Equatable { + case disabled + case radius(Int) + case macosGlassRegular + case macosGlassClear + + init(fromCValue value: Int16) { + switch value { + case 0: + self = .disabled + case -1: + self = .macosGlassRegular + case -2: + self = .macosGlassClear + default: + self = .radius(Int(value)) + } + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } + + /// Returns true if this is a macOS glass style (regular or clear). + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + default: + return false + } + } + + /// Returns the blur radius if applicable, nil for glass effects. + var radius: Int? { + switch self { + case .disabled: + return nil + case .radius(let r): + return r + case .macosGlassRegular, .macosGlassClear: + return nil + } + } + } + struct BellFeatures: OptionSet { let rawValue: CUnsignedInt @@ -635,6 +697,12 @@ extension Ghostty.Config { static let title = BellFeatures(rawValue: 1 << 3) static let border = BellFeatures(rawValue: 1 << 4) } + + struct SplitPreserveZoom: OptionSet { + let rawValue: CUnsignedInt + + static let navigation = SplitPreserveZoom(rawValue: 1 << 0) + } enum MacDockDropBehavior: String { case new_tab = "new-tab" diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4b3eb60aa..b834ea31f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -56,7 +56,7 @@ extension Ghostty { case app case zig_run } - + /// Returns the mechanism that launched the app. This is based on an env var so /// its up to the env var being set in the correct circumstance. static var launchSource: LaunchSource { @@ -65,7 +65,7 @@ extension Ghostty { // source. If its unset we assume we're in a CLI environment. return .cli } - + // If the env var is set but its unknown then we default back to the app. return LaunchSource(rawValue: envValue) ?? .app } @@ -76,17 +76,17 @@ extension Ghostty { extension Ghostty { class AllocatedString { private let cString: ghostty_string_s - + init(_ c: ghostty_string_s) { self.cString = c } - + var string: String { guard let ptr = cString.ptr else { return "" } let data = Data(bytes: ptr, count: Int(cString.len)) return String(data: data, encoding: .utf8) ?? "" } - + deinit { ghostty_string_free(cString) } @@ -391,6 +391,10 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ba678db59..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -116,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { @@ -757,6 +764,96 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } + } + .padding(8) + + Spacer() + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 130df6f44..d26545ebc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -123,6 +123,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -333,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -703,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } @@ -1416,6 +1429,9 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") @@ -1499,6 +1515,14 @@ extension Ghostty { } } + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1975,6 +1999,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { case #selector(findHide): return searchState != nil + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true + default: return true } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 09c41c0b5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,6 +43,9 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/nix/devShell.nix b/nix/devShell.nix index 4aaf4ef5c..d37107133 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -70,7 +70,6 @@ wayland-scanner, wayland-protocols, zon2nix, - system, pkgs, # needed by GTK for loading SVG icons while running from within the # developer shell @@ -100,7 +99,7 @@ in scdoc zig zip - zon2nix.packages.${system}.zon2nix + zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix # For web and wasm stuff nodejs diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..a9970e80c --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,283 @@ +{ + self, + system, + nixpkgs, + home-manager, + ... +}: let + nixos-version = nixpkgs.lib.trivial.release; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + self.overlays.debug + ]; + }; + + pink_value = "#FF0087"; + + color_test = '' + import tempfile + import subprocess + + def check_for_pink(final=False) -> bool: + with tempfile.NamedTemporaryFile() as tmpin: + machine.send_monitor_command("screendump {}".format(tmpin.name)) + + cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format( + tmpin.name + ) + ret = subprocess.run(cmd, shell=True, capture_output=True) + if ret.returncode != 0: + raise Exception( + "image analysis failed with exit code {}".format(ret.returncode) + ) + + text = ret.stdout.decode("utf-8") + return "${pink_value}" in text + ''; + + mkNodeGnome = { + config, + pkgs, + settings, + sshPort ? null, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation = { + forwardPorts = pkgs.lib.optionals (sshPort != null) [ + { + from = "host"; + host.port = sshPort; + guest.port = 22; + } + ]; + + vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + programs.ssh = { + enable = true; + extraOptionOverrides = { + StrictHostKeyChecking = "accept-new"; + UserKnownHostsFile = "/dev/null"; + }; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + + mkTestGnome = { + name, + settings, + testScript, + ocr ? false, + }: + pkgs.testers.runNixOSTest { + name = name; + + enableOCR = ocr; + + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + + nodes = { + machine = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs settings; + sshPort = 2222; + }; + }; + + testScript = testScript; + }; +in { + basic-version-check = pkgs.testers.runNixOSTest { + name = "basic-version-check"; + nodes = { + machine = {pkgs, ...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = [ + pkgs.ghostty + ]; + }; + }; + }; + testScript = {...}: '' + machine.succeed("su - ghostty -c 'ghostty +version'") + ''; + }; + + basic-window-check-gnome = mkTestGnome { + name = "basic-window-check-gnome"; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + background = ${pink_value} + ''; + }; + }; + }; + ocr = true; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + ${color_test} + + with subtest("wait for x"): + start_all() + machine.wait_for_x() + + machine.wait_for_file("${bus_path}") + + with subtest("Ensuring no pink is present without the terminal."): + assert ( + check_for_pink() == False + ), "Pink was present on the screen before we even launched a terminal!" + + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + machine.succeed("${su "${ghostty} +new-window"}") + machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + machine.sleep(2) + + with subtest("Have the terminal display a color."): + assert( + check_for_pink() == True + ), "Pink was not found on the screen!" + + machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") + ''; + }; + + ssh-integration-test = pkgs.testers.runNixOSTest { + name = "ssh-integration-test"; + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + nodes = { + server = {...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = []; + }; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + security.pam.services.sshd.allowNullPassword = true; + }; + client = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = let + in '' + shell-integration-features = ssh-terminfo + ''; + }; + }; + }; + sshPort = 2222; + }; + }; + testScript = {nodes, ...}: let + user = nodes.client.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + with subtest("Start server and wait for ssh to be ready."): + server.start() + server.wait_for_open_port(22) + + with subtest("Start client and wait for ghostty window."): + client.start() + client.wait_for_x() + client.wait_for_file("${bus_path}") + client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + client.succeed("${su "${ghostty} +new-window"}") + client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + client.sleep(2) + client.send_chars("ssh ghostty@server\n") + server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) + ''; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 0c2bef150..ab4aab9e9 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -22,6 +22,19 @@ }; }; + systemd.user.services = { + "org.gnome.Shell@wayland" = { + serviceConfig = { + ExecStart = [ + # Clear the list before overriding it. + "" + # Eval API is now internal so Shell needs to run in unsafe mode. + "${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode" + ]; + }; + }; + }; + environment.systemPackages = [ pkgs.gnomeExtensions.no-overview ]; diff --git a/nix/vm/common.nix b/nix/vm/common.nix index eefd7c1c0..b2fec28e8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -4,9 +4,6 @@ documentation.nixos.enable = false; - networking.hostName = "ghostty"; - networking.domain = "mitchellh.com"; - virtualisation.vmVariant = { virtualisation.memorySize = 2048; }; @@ -28,17 +25,11 @@ users.groups.ghostty = {}; users.users.ghostty = { + isNormalUser = true; description = "Ghostty"; group = "ghostty"; extraGroups = ["wheel"]; - isNormalUser = true; - initialPassword = "ghostty"; - }; - - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; + hashedPassword = ""; }; environment.systemPackages = [ @@ -61,6 +52,7 @@ services.displayManager = { autoLogin = { + enable = true; user = "ghostty"; }; }; diff --git a/src/Surface.zig b/src/Surface.zig index 8cd8d253b..d84e786f3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -145,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// (native) scrolling, and copy keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -601,6 +607,9 @@ pub fn init( }; errdefer env.deinit(); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, @@ -812,6 +821,30 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } +/// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + + self.io.queueMessage(msg, mutex); +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -843,7 +876,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -860,7 +893,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -871,6 +904,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -929,7 +965,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -1121,7 +1157,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. if (self.mouse.left_click_screen != t.screens.active_key) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -1353,7 +1389,7 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .write_stable = output }, .unlocked); } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { @@ -1726,7 +1762,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -2001,6 +2037,23 @@ pub fn pwd( return try alloc.dupe(u8, terminal_pwd); } +/// Resolves a relative file path to an absolute path using the terminal's pwd. +fn resolvePathForOpening( + self: *Surface, + path: []const u8, +) Allocator.Error!?[]const u8 { + if (!std.fs.path.isAbsolute(path)) { + const terminal_pwd = self.io.terminal.getPwd() orelse { + return null; + }; + + const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); + return resolved; + } + + return null; +} + /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { @@ -2292,7 +2345,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2395,7 +2448,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2671,7 +2724,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2900,7 +2953,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -3126,7 +3179,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3290,7 +3343,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -3511,7 +3564,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3534,7 +3587,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3555,7 +3608,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3572,7 +3625,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3601,7 +3654,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3753,7 +3806,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -4110,7 +4163,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -4120,7 +4173,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -4229,7 +4282,12 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try self.openUrl(.{ .kind = .unknown, .url = str }); + + const resolved_path = try self.resolvePathForOpening(str); + defer if (resolved_path) |p| self.alloc.free(p); + + const url_to_open = resolved_path orelse str; + try self.openUrl(.{ .kind = .unknown, .url = url_to_open }); }, ._open_osc8 => { @@ -4393,7 +4451,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4493,7 +4551,7 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); @@ -4869,7 +4927,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4896,7 +4954,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4929,9 +4987,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -5204,19 +5262,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, @@ -5246,14 +5304,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -5261,19 +5319,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -5357,6 +5415,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + .previous => .previous, + .next => .next, + }, + ), + .resize_split => |value| return try self.rt_app.performAction( .{ .surface = self }, .resize_split, @@ -5383,6 +5450,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); + return true; + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, @@ -5441,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_background_opacity => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_background_opacity, + {}, + ), + .show_on_screen_keyboard => return try self.rt_app.performAction( .{ .surface = self }, .show_on_screen_keyboard, @@ -5488,7 +5571,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { @@ -5686,7 +5769,7 @@ fn writeScreenFile( }, .url = path, }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5826,7 +5909,7 @@ fn completeClipboardPaste( }; for (vecs) |vec| if (vec.len > 0) { - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, vec, ), .unlocked); @@ -5872,7 +5955,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..8e0a9d018 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -115,6 +115,11 @@ pub const Action = union(Key) { /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, + /// Toggle the window background opacity. This only has an effect + /// if the window started as transparent (non-opaque), and toggles + /// it between fully opaque and the configured background opacity. + toggle_background_opacity, + /// Moves a tab by a relative offset. /// /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 @@ -129,6 +134,9 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, + /// Jump to next/previous window. + goto_window: GotoWindow, + /// Resize the split in the given direction. resize_split: ResizeSplit, @@ -314,6 +322,9 @@ pub const Action = union(Key) { /// The currently selected search match index (1-based). search_selected: SearchSelected, + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -329,9 +340,11 @@ pub const Action = union(Key) { toggle_quick_terminal, toggle_command_palette, toggle_visibility, + toggle_background_opacity, move_tab, goto_tab, goto_split, + goto_window, resize_split, equalize_splits, toggle_split_zoom, @@ -375,6 +388,7 @@ pub const Action = union(Key) { end_search, search_total, search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -470,6 +484,13 @@ pub const GotoSplit = enum(c_int) { right, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, +}; + /// The amount to resize the split by and the direction to resize it in. pub const ResizeSplit = extern struct { amount: u16, @@ -532,6 +553,11 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..be0f3f2c8 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -8,6 +8,8 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const build_config = @import("../../../build_config.zig"); +const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -659,6 +661,8 @@ pub const Application = extern struct { .goto_split => return Action.gotoSplit(target, value), + .goto_window => return Action.gotoWindow(value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -737,6 +741,7 @@ pub const Application = extern struct { .close_all_windows, .float_window, .toggle_visibility, + .toggle_background_opacity, .cell_size, .key_sequence, .render_inspector, @@ -746,6 +751,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2013,6 +2019,69 @@ const Action = struct { } } + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; + + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + return false; + } + + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, @@ -2611,7 +2680,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { /// disable it. @"vulkan-disable": bool = false, } = .{ - .opengl = config.@"gtk-opengl-debug", + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 48656c951..46b3268d9 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -340,6 +340,35 @@ pub const SplitTree = extern struct { const surface = tree.nodes[target.idx()].leaf; surface.grabFocus(); + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + if (tree.zoomed != null) { + const app = Application.default(); + const config_obj = app.getConfig(); + defer config_obj.unref(); + const config = config_obj.get(); + + if (!config.@"split-preserve-zoom".navigation) { + tree.zoomed = null; + } else { + tree.zoom(target); + } + + // When the zoom state changes our tree state changes and + // we need to send the proper notifications to trigger + // relayout. + const object = self.as(gobject.Object); + object.notifyByPspec(properties.tree.impl.param_spec); + object.notifyByPspec(properties.@"is-zoomed".impl.param_spec); + } + return true; } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 548ae1a6a..93d1beeb2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1658,13 +1658,7 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); - // Initialize our GLArea. We only set the values we can't set - // in our blueprint file. - const gl_area = priv.gl_area; - gl_area.setRequiredVersion( - renderer.OpenGL.MIN_VERSION_MAJOR, - renderer.OpenGL.MIN_VERSION_MINOR, - ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); // Initialize our config diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c691b84a6..77fd2eea5 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -793,7 +793,7 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - fn getActiveSurface(self: *Self) ?*Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { const tab = self.getSelectedTab() orelse return null; return tab.getActiveSurface(); } diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 9dc273563..1e73c6139 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,6 +173,12 @@ pub const Window = struct { blur_region: Region = .{}, + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 + last_applied_blur_region: ?Region = null, + last_applied_decoration_hints: ?MotifWMHints = null, + pub fn init( alloc: Allocator, app: *App, @@ -255,30 +261,42 @@ pub const Window = struct { const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + if (!blur.enabled()) { + if (self.last_applied_blur_region != null) { + try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = null; + } + + return; + } + // Transform surface coordinates to device coordinates. const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = config.@"background-blur"; + // Only update X11 properties when the blur region actually changes + if (self.last_applied_blur_region) |last| { + if (std.meta.eql(self.blur_region, last)) return; + } + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - if (blur.enabled()) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); - } + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + self.last_applied_blur_region = self.blur_region; } fn syncDecorations(self: *Window) !void { @@ -307,6 +325,11 @@ pub const Window = struct { .auto, .client, .none => false, }; + // Only update decoration hints when they actually change + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; + } + try self.changeProperty( MotifWMHints, self.app.atoms.motif_wm_hints, @@ -315,6 +338,7 @@ pub const Window = struct { .{ .mode = .replace }, &hints, ); + self.last_applied_decoration_hints = hints; } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig new file mode 100644 index 000000000..6243aba7d --- /dev/null +++ b/src/benchmark/OscParser.zig @@ -0,0 +1,118 @@ +//! This benchmark tests the throughput of the OSC parser. +const OscParser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Parser = @import("../terminal/osc.zig").Parser; +const log = std.log.scoped(.@"osc-parser-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +parser: Parser, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*OscParser { + const ptr = try alloc.create(OscParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .data_f = null, + .parser = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *OscParser, alloc: Allocator) void { + self.parser.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *OscParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + self.parser.reset(); +} + +fn teardown(ptr: *anyopaque) void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var r = f.reader(&read_buf); + + var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + while (true) { + r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + const len = r.interface.takeInt(usize, .little) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + if (len > osc_buf.len) return error.BenchmarkFailed; + + r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + for (osc_buf[0..len]) |c| self.parser.next(c); + _ = self.parser.end(std.ascii.control_code.bel); + self.parser.reset(); + } +} + +test OscParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *OscParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 816ecd3f6..13f070774 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -12,6 +12,7 @@ pub const Action = enum { @"terminal-parser", @"terminal-stream", @"is-symbol", + @"osc-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -29,6 +30,7 @@ pub const Action = enum { .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"is-symbol" => @import("IsSymbol.zig"), + .@"osc-parser" => @import("OscParser.zig"), }; } }; diff --git a/src/build/Config.zig b/src/build/Config.zig index 981cd7de5..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { else version: { const app_version = try std.SemanticVersion.parse(appVersion); - // Detect if ghostty is being built as a dependency by checking if the - // build root has our marker. When used as a dependency, we skip git - // detection entirely to avoid reading the downstream project's git state. - const is_dependency = !@hasDecl( - @import("root"), - "_ghostty_build_root", - ); - if (is_dependency) { - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; - } + // Is ghostty a dependency? If so, skip git detection. + // @src().file won't resolve from b.build_root unless ghostty + // is the project being built. + b.build_root.handle.access(@src().file, .{}) catch break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 27691d744..5ca4c5e9a 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -151,7 +151,7 @@ pub fn init( // This overrides our default behavior and forces logs to show // up on stderr (in addition to the centralized macOS log). - open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos"); // Configure how we're launching open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run"); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 88aa16273..a63a85fd4 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -37,6 +37,19 @@ precedence over the XDG environment locations. : **WINDOWS ONLY:** alternate location to search for configuration files. +**GHOSTTY_LOG** + +: The `GHOSTTY_LOG` environment variable can be used to control which +destinations receive logs. Ghostty currently defines two destinations: + +: - `stderr` - logging to `stderr`. +: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +: Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + # BUGS See GitHub issues: diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index b9d4cb751..ce3196eb6 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration. ## Configuration Errors If your configuration file has any errors, Ghostty does its best to ignore -them and move on. Configuration errors currently show up in the log. The log -is written directly to stderr, so it is up to you to figure out how to access -that for your system (for now). On macOS, you can also use the system `log` CLI -utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. +them and move on. Configuration errors will be logged. ## Debugging Configuration You can verify that configuration is being properly loaded by looking at the -debug output of Ghostty. Documentation for how to view the debug output is in -the "building Ghostty" section at the end of the README. +debug output of Ghostty. In the debug output, you should see in the first 20 lines or so messages about loading (or not loading) a configuration file, as well as any errors it may have @@ -93,3 +89,34 @@ will fall back to default values for erroneous keys. You can also view the full configuration Ghostty is loading using `ghostty +show-config` from the command-line. Use the `--help` flag to additional options for that command. + +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug +--predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. diff --git a/src/cli/args.zig b/src/cli/args.zig index 43a15ca06..bd5060d69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -604,7 +604,7 @@ pub fn parseAutoStruct( return result; } -fn parsePackedStruct(comptime T: type, v: []const u8) !T { +pub fn parsePackedStruct(comptime T: type, v: []const u8) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 45a80723e..42aff9d56 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -2,6 +2,7 @@ const std = @import("std"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); +const configpkg = @import("../config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); const global_state = &@import("../global.zig").state; @@ -196,6 +197,31 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 0; } +fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 { + const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc); + defer alloc.free(main_cfg_path); + + const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName; + return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" }); +} + +fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { + const auto_path = try resolveAutoThemePath(alloc); + defer alloc.free(auto_path); + + if (std.fs.path.dirname(auto_path)) |dir| { + try std.fs.cwd().makePath(dir); + } + + var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true }); + defer f.close(); + + var buf: [128]u8 = undefined; + var w = f.writer(&buf); + try w.interface.print("theme = {s}\n", .{theme_name}); + try w.interface.flush(); +} + const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, @@ -487,6 +513,9 @@ const Preview = struct { self.should_quit = true; if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) self.mode = .normal; + if (key.matches('w', .{})) { + self.saveSelectedTheme(); + } }, } }, @@ -698,7 +727,7 @@ const Preview = struct { .help => { win.hideCursor(); const width = 60; - const height = 20; + const height = 22; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -733,6 +762,7 @@ const Preview = struct { .{ .keys = "/", .help = "Start search." }, .{ .keys = "^X, ^/", .help = "Clear search." }, .{ .keys = "⏎", .help = "Save theme or close search window." }, + .{ .keys = "w", .help = "Write theme to auto config file." }, }; for (key_help, 0..) |help, captured_i| { @@ -786,8 +816,8 @@ const Preview = struct { .save => { const theme = self.themes[self.filtered.items[self.current]]; - const width = 90; - const height = 12; + const width = 92; + const height = 17; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -809,6 +839,12 @@ const Preview = struct { try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}), "", "Save the configuration file and then reload it to apply the new theme.", + "", + "Or press 'w' to write an auto theme file to your system's preferred default config path.", + "Then add the following line to your Ghostty configuration and reload:", + "", + "config-file = ?auto/theme.ghostty", + "", "For more details on configuration and themes, visit the Ghostty documentation:", "", "https://ghostty.org/docs/config/reference", @@ -1657,6 +1693,18 @@ const Preview = struct { }); } } + + fn saveSelectedTheme(self: *Preview) void { + if (self.filtered.items.len == 0) + return; + + const idx = self.filtered.items[self.current]; + const theme = self.themes[idx]; + + writeAutoThemeFile(self.allocator, theme.theme) catch { + return; + }; + } }; fn color(config: Config, palette: usize) vaxis.Color { diff --git a/src/config/Config.zig b/src/config/Config.zig index 20256e951..1aad62d7d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -86,6 +86,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". // The semantics also changed slightly but this is the correct mapping. .{ "gtk-single-instance", compatGtkSingleInstance }, + + // Ghostty 1.3 rename the "window" option to "new-window". + // See: https://github.com/ghostty-org/ghostty/pull/9764 + .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); /// The font families to use. @@ -927,6 +931,15 @@ palette: Palette = .{}, /// reasonable for a good looking blur. Higher blur intensities may /// cause strange rendering and performance issues. /// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -976,6 +989,22 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// Control when Ghostty preserves a zoomed split. Under normal circumstances, +/// any operation that changes focus or layout of the split tree in a window +/// will unzoom any zoomed split. This configuration allows you to control +/// this behavior. +/// +/// This can be set to `navigation` to preserve the zoomed split state +/// when navigating to another split (e.g. via `goto_split`). This will +/// change the zoomed split to the newly focused split instead of unzooming. +/// +/// Any options can also be prefixed with `no-` to disable that option. +/// +/// Example: `split-preserve-zoom = navigation` +/// +/// Available since: 1.3.0 +@"split-preserve-zoom": SplitPreserveZoom = .{}, + /// The foreground and background color for search matches. This only applies /// to non-focused search matches, also known as candidate matches. /// @@ -1329,7 +1358,7 @@ maximize: bool = false, /// new windows, not just the first one. /// /// On macOS, this setting does not work if window-decoration is set to -/// "false", because native fullscreen on macOS requires window decorations +/// "none", because native fullscreen on macOS requires window decorations /// to be set. fullscreen: bool = false, @@ -2825,7 +2854,7 @@ keybind: Keybinds = .{}, /// also known as the traffic lights, that allow you to close, miniaturize, and /// zoom the window. /// -/// This setting has no effect when `window-decoration = false` or +/// This setting has no effect when `window-decoration = none` or /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in /// these modes. /// @@ -2866,7 +2895,7 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// -/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect /// existing windows in buggy ways. @@ -2911,7 +2940,7 @@ keybind: Keybinds = .{}, /// /// * `new-tab` - Create a new tab in the current window, or open /// a new window if none exist. -/// * `window` - Create a new window unconditionally. +/// * `new-window` - Create a new window unconditionally. /// /// The default value is `new-tab`. /// @@ -3205,7 +3234,7 @@ else /// manager's simple titlebar. The behavior of this option will vary with your /// window manager. /// -/// This option does nothing when `window-decoration` is false or when running +/// This option does nothing when `window-decoration` is none or when running /// under macOS. @"gtk-titlebar": bool = true, @@ -4445,6 +4474,23 @@ fn compatBoldIsBright( return true; } +fn compatMacOSDockDropBehavior( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "macos-dock-drop-behavior")); + + if (std.mem.eql(u8, value orelse "", "window")) { + self.@"macos-dock-drop-behavior" = .@"new-window"; + return true; + } + + return false; +} + /// Add a diagnostic message to the config with the given string. /// This is always added with a location of "none". pub fn addDiagnosticFmt( @@ -7414,6 +7460,10 @@ pub const ShellIntegrationFeatures = packed struct { path: bool = true, }; +pub const SplitPreserveZoom = packed struct { + navigation: bool = false, +}; + pub const RepeatableCommand = struct { value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, @@ -7875,7 +7925,7 @@ pub const WindowNewTabPosition = enum { /// See macos-dock-drop-behavior pub const MacOSDockDropBehavior = enum { @"new-tab", - window, + @"new-window", }; /// See window-show-tab-bar @@ -8315,6 +8365,8 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, + @"macos-glass-regular", + @"macos-glass-clear", radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { @@ -8324,14 +8376,35 @@ pub const BackgroundBlur = union(enum) { return; }; - self.* = if (cli.args.parseBool(input_)) |b| - if (b) .true else .false - else |_| - .{ .radius = std.fmt.parseInt( - u8, - input_, - 0, - ) catch return error.InvalidValue }; + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn enabled(self: BackgroundBlur) bool { @@ -8339,14 +8412,24 @@ pub const BackgroundBlur = union(enum) { .false => false, .true => true, .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, }; } - pub fn cval(self: BackgroundBlur) u8 { + pub fn cval(self: BackgroundBlur) i16 { return switch (self) { .false => 0, .true => 20, .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, }; } @@ -8358,6 +8441,8 @@ pub const BackgroundBlur = union(enum) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), } } @@ -8377,6 +8462,12 @@ pub const BackgroundBlur = union(enum) { try v.parseCLI("42"); try testing.expectEqual(42, v.radius); + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); try testing.expectError(error.InvalidValue, v.parseCLI("420")); @@ -9491,3 +9582,22 @@ test "compatibility: removed bold-is-bright" { ); } } + +test "compatibility: window new-window" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--macos-dock-drop-behavior=window", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + try testing.expectEqual( + MacOSDockDropBehavior.@"new-window", + cfg.@"macos-dock-drop-behavior", + ); + } +} diff --git a/src/config/c_get.zig b/src/config/c_get.zig index f235f596a..dcfdc6716 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -193,20 +193,48 @@ test "c_get: background-blur" { { c.@"background-blur" = .false; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { c.@"background-blur" = .true; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { c.@"background-blur" = .{ .radius = 42 }; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } + { + c.@"background-blur" = .@"macos-glass-regular"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-1, cval); + } + { + c.@"background-blur" = .@"macos-glass-clear"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-2, cval); + } +} + +test "c_get: split-preserve-zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + var bits: c_uint = undefined; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 0), bits); + + c.@"split-preserve-zoom".navigation = true; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 1), bits); } diff --git a/src/config/url.zig b/src/config/url.zig index da3928aff..fdbc964d7 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -26,7 +26,7 @@ pub const regex = "(?:" ++ url_schemes ++ \\)(?: ++ ipv6_url_pattern ++ - \\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? 0) { - self.logging = .{ .stderr = {} }; - } + self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{}; } // Setup our signal handlers before logging diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e1c636ab7..9f3ad8a2a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,6 +545,9 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: GotoWindow, + /// Zoom in or out of the current split. /// /// When a split is zoomed into, it will take up the entire space in @@ -552,6 +555,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// Resize the current split in the specified direction and amount in /// pixels. The two arguments should be joined with a comma (`,`), /// like in `resize_split:up,10`. @@ -742,6 +755,16 @@ pub const Action = union(enum) { /// Only implemented on macOS. toggle_visibility, + /// Toggle the window background opacity between transparent and opaque. + /// + /// This does nothing when `background-opacity` is set to 1 or above. + /// + /// When `background-opacity` is less than 1, this action will either make + /// the window transparent or not depending on its current transparency state. + /// + /// Only implemented on macOS. + toggle_background_opacity, + /// Check for updates. /// /// Only implemented on macOS. @@ -921,6 +944,11 @@ pub const Action = union(enum) { right, }; + pub const GotoWindow = enum { + previous, + next, + }; + pub const SplitResizeParameter = struct { SplitResizeDirection, u16, @@ -1222,6 +1250,7 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_mouse_reporting, .toggle_command_palette, + .toggle_background_opacity, .show_on_screen_keyboard, .reset_window_size, .crash, @@ -1240,7 +1269,9 @@ pub const Action = union(enum) { .toggle_tab_overview, .new_split, .goto_split, + .goto_window, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, diff --git a/src/input/command.zig b/src/input/command.zig index 639fc6e39..6ac4312a9 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -479,12 +479,31 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .next }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", @@ -599,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle whether mouse events are reported to terminal applications.", }}, + .toggle_background_opacity => comptime &.{.{ + .action = .toggle_background_opacity, + .title = "Toggle Background Opacity", + .description = "Toggle the background opacity of a window that started transparent.", + }}, + .check_for_updates => comptime &.{.{ .action = .check_for_updates, .title = "Check for Updates", diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index b63de6f6d..736df58a0 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -178,7 +178,7 @@ fn kitty( // Quote ("report all" mode): // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. - if (effective_mods.empty()) { + if (binding_mods.empty()) { switch (event.key) { .enter => return try writer.writeByte('\r'), .tab => return try writer.writeByte('\t'), @@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" { try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } -// + +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + test "kitty: enter with all flags" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 261e0ad7d..72d602989 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -118,19 +118,17 @@ fn logFn( comptime format: []const u8, args: anytype, ) void { - // Stuff we can do before the lock - const level_txt = comptime level.asText(); - const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; - - // Lock so we are thread-safe - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - // On Mac, we use unified logging. To view this: // // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' // - if (builtin.target.os.tag.isDarwin()) { + // macOS logging is thread safe so no need for locks/mutexes + macos: { + if (comptime !builtin.target.os.tag.isDarwin()) break :macos; + if (!state.logging.macos) break :macos; + + const prefix = if (scope == .default) "" else @tagName(scope) ++ ": "; + // Convert our levels to Mac levels const mac_level: macos.os.LogType = switch (level) { .debug => .debug, @@ -143,26 +141,35 @@ fn logFn( // but we shouldn't be logging too much. const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, format, args); + logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); } - switch (state.logging) { - .disabled => {}, + stderr: { + // don't log debug messages to stderr unless we are a debug build + if (comptime builtin.mode != .Debug and level == .debug) break :stderr; - .stderr => { - // Always try default to send to stderr - var buffer: [1024]u8 = undefined; - var stderr = std.fs.File.stderr().writer(&buffer); - const writer = &stderr.interface; - nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return; - // TODO: Do we want to use flushless stderr in the future? - writer.flush() catch {}; - }, + // skip if we are not logging to stderr + if (!state.logging.stderr) break :stderr; + + // Lock so we are thread-safe + var buf: [64]u8 = undefined; + const stderr = std.debug.lockStderrWriter(&buf); + defer std.debug.unlockStderrWriter(); + + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr; + nosuspend stderr.flush() catch break :stderr; } } pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. + // + // Note, we don't lower this to debug even with conditional logging + // via GHOSTTY_LOG because our debug logs are very expensive to + // calculate and we want to make sure they're optimized out in + // builds. .log_level = switch (builtin.mode) { .Debug => .debug, else => .info, diff --git a/src/os/shell.zig b/src/os/shell.zig index 9fce3e385..fe8f1b2fd 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,7 +1,84 @@ const std = @import("std"); const testing = std.testing; +const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig index 162023ad2..042001ea7 100644 --- a/src/os/string_encoding.zig +++ b/src/os/string_encoding.zig @@ -265,3 +265,16 @@ test "percent 7" { @memcpy(&src, s); try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); } + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index da577f957..4b01da0c5 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -137,15 +137,7 @@ fn prepareContext(getProcAddress: anytype) !void { errdefer gl.glad.unload(); log.info("loaded OpenGL {}.{}", .{ major, minor }); - // Enable debug output for the context. - try gl.enable(gl.c.GL_DEBUG_OUTPUT); - - // Register our debug message callback with the OpenGL context. - gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); - - // Enable SRGB framebuffer for linear blending support. - try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); - + // Need to check version before trying to enable it if (major < MIN_VERSION_MAJOR or (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR)) { @@ -155,6 +147,15 @@ fn prepareContext(getProcAddress: anytype) !void { ); return error.OpenGLOutdated; } + + // Enable debug output for the context. + try gl.enable(gl.c.GL_DEBUG_OUTPUT); + + // Register our debug message callback with the OpenGL context. + gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); + + // Enable SRGB framebuffer for linear blending support. + try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); } /// This is called early right after surface creation. diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c55da602..39eec7b43 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, + background_blur: configpkg.Config.BackgroundBlur, pub fn init( alloc_gpa: Allocator, @@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", + .background_blur = config.@"background-blur", .arena = arena, }; } @@ -716,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, + // Note that if we're on macOS with glass effects + // we'll disable background opacity but we handle + // that in updateFrame. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1295,6 +1300,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.b, @intFromFloat(@round(self.config.background_opacity * 255.0)), }; + + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, + }; } } diff --git a/src/renderer/size.zig b/src/renderer/size.zig index b26c1581e..d8b529c26 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -44,6 +44,15 @@ pub const Size = struct { self.grid(), self.cell, ); + + // The top/bottom padding is interesting. Subjectively, lots of padding + // at the top looks bad. So instead of always being equal (like left/right), + // we force the top padding to be at most equal to the maximum left padding, + // which is the balanced explicit horizontal padding plus half a cell width. + const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_padding_left; + self.padding.top -= vshift; + self.padding.bottom += vshift; } }; @@ -258,16 +267,12 @@ pub const Padding = struct { const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; - // The left/right padding is just an equal split. + // The padding is split equally along both axes. const padding_right = @floor(space_right / 2); const padding_left = padding_right; - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the left, and the bottom - // padding is the difference thereafter. - const padding_top = @min(padding_left, @floor(space_bot / 2)); - const padding_bot = space_bot - padding_top; + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; const zero = @as(f32, 0); return .{ diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 3f8543c68..9c422ef26 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -78,10 +78,16 @@ on the Fish startup process, see the ### Zsh -For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration -from the `zsh` directory. The existing `ZDOTDIR` is retained so that -after loading the Ghostty shell integration the normal Zsh loading -sequence occurs. +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. + +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: ```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index e910a9885..799d0cff6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 33473c8b0..e4b449ae5 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -97,7 +97,7 @@ if (not (has-value $arg =)) { break } } - if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] } + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } (external sudo) $@args } 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 47af9be98..580e27f45 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 @@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$sudo_has_sudoedit_flags" = "yes" command sudo $argv else - command sudo TERMINFO="$TERMINFO" $argv + command sudo --preserve-env=TERMINFO $argv end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7ff43efd9..febf3e59c 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -93,9 +93,6 @@ _entrypoint() { _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # The directory where ghostty-integration is located: /../shell-integration/zsh. - builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" - # Enable semantic markup with OSC 133. _ghostty_precmd() { builtin local -i cmd_status=$? @@ -255,7 +252,7 @@ _ghostty_deferred_init() { if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index b43079e1a..00de43f7f 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -5,12 +5,23 @@ const std = @import("std"); const assert = std.debug.assert; const Generator = @import("Generator.zig"); const Bytes = @import("Bytes.zig"); +const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode; /// Valid OSC request kinds that can be generated. pub const ValidKind = enum { change_window_title, prompt_start, prompt_end, + end_of_input, + end_of_command, + rxvt_notify, + mouse_shape, + clipboard_operation, + report_pwd, + hyperlink_start, + hyperlink_end, + conemu_progress, + iterm2_notification, }; /// Invalid OSC request kinds that can be generated. @@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool { /// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); +pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint); +pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic); +pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin if (max_len < 4) break :prompt_end; try writer.writeAll("133;B"); // End prompt }, + + .end_of_input => end_of_input: { + if (max_len < 5) break :end_of_input; + var remaining = max_len; + try writer.writeAll("133;C"); // End prompt + remaining -= 5; + if (self.rand.boolean()) cmdline: { + const prefix = ";cmdline_url="; + if (remaining < prefix.len + 1) break :cmdline; + try writer.writeAll(prefix); + remaining -= prefix.len; + var buf: [128]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w); + try urlPercentEncode(writer, w.buffered()); + remaining -= w.buffered().len; + } + }, + + .end_of_command => end_of_command: { + if (max_len < 4) break :end_of_command; + try writer.writeAll("133;D"); // End prompt + if (self.rand.boolean()) exit_code: { + if (max_len < 7) break :exit_code; + try writer.print(";{d}", .{self.rand.int(u8)}); + } + }, + + .mouse_shape => mouse_shape: { + if (max_len < 4) break :mouse_shape; + try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt + }, + + .rxvt_notify => rxvt_notify: { + const prefix = "777;notify;"; + if (max_len < prefix.len) break :rxvt_notify; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer); + try writer.writeByte(';'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + }, + + .clipboard_operation => { + try writer.writeAll("52;"); + var remaining = max_len - 3; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer); + } + try writer.writeByte(';'); + remaining -= 1; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + } + }, + + .report_pwd => report_pwd: { + const prefix = "7;file://localhost"; + if (max_len < prefix.len) break :report_pwd; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.writeByte('/'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer); + } + }, + + .hyperlink_start => { + try writer.writeAll("8;"); + if (self.rand.boolean()) { + try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + try writer.writeAll(";https://localhost"); + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + }, + + .hyperlink_end => hyperlink_end: { + if (max_len < 3) break :hyperlink_end; + try writer.writeAll("8;;"); + }, + + .conemu_progress => { + try writer.writeAll("9;"); + switch (self.rand.intRangeAtMost(u3, 0, 4)) { + 0, 3 => |c| { + try writer.print(";{d}", .{c}); + }, + 1, 2, 4 => |c| { + if (self.rand.boolean()) { + try writer.print(";{d}", .{c}); + } else { + try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) }); + } + }, + else => unreachable, + } + }, + + .iterm2_notification => iterm2_notification: { + if (max_len < 3) break :iterm2_notification; + // add a prefix to ensure that this is not interpreted as a ConEmu OSC + try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)}); + }, } } diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 22ca1ffb5..d416189ce 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { var gen: Bytes = .{ .rand = rand, .alphabet = ascii, + .min_len = 1024, + .max_len = 1024, }; while (true) { - gen.next(writer, 1024) catch |err| { + _ = gen.write(writer) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 8250b81de..686563fc3 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct { /// Probability of generating a valid value. @"p-valid": f64 = 0.5, + + style: enum { + /// Write all OSC data, including ESC ] and ST for end-to-end tests + streaming, + /// Only write data, prefixed with a length, used for testing just the + /// OSC parser. + parser, + } = .streaming, }; opts: Options, @@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var fixed: std.Io.Writer = .fixed(&buf); try gen.next(&fixed, buf.len); const data = fixed.buffered(); - writer.writeAll(data) catch |err| switch (err) { - error.WriteFailed => return, - }; + switch (self.opts.style) { + .streaming => { + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + .parser => { + writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) { + error.WriteFailed => return, + }; + writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + } } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b0d43c192..6c9d8b585 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void { // this check. !self.screens.active.blankCell().isZero()) { - self.scrollUp(1); + try self.scrollUp(1); return; } @@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// The new lines are created according to the current SGR state. /// /// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { +pub fn scrollUp(self: *Terminal, count: usize) !void { // Preserve our x/y to restore. const old_x = self.screens.active.cursor.x; const old_y = self.screens.active.cursor.y; @@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); @@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" { t.setCursorPos(2, 2); const cursor = t.screens.active.cursor; - t.clearDirty(); - t.scrollUp(1); + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); { const str = try t.plainString(testing.allocator); @@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" { t.setCursorPos(1, 1); t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" { const cursor = t.screens.active.cursor; t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); @@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" { try t.print('B'); t.setCursorPos(3, 5); try t.print('C'); - t.scrollUp(1); + try t.scrollUp(1); try t.print('X'); { @@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" { t.setTopAndBottomMargin(2, 5); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.setLeftAndRightMargin(2, 4); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +test "Terminal: scrollUp creates scrollback in primary screen" { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback is 0, scrollUp should still work but not retain history + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 3b088e2b7..c33dba1bb 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -100,7 +100,7 @@ pub const Handler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 53df00433..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -22,6 +22,9 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.io_exec); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index a79e38639..71492230e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,8 +259,9 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); - defer args.deinit(alloc); + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); // Iterator that yields each argument in the original command line. // This will allocate once proportionate to the command line length. @@ -269,9 +270,9 @@ fn setupBash( // Start accumulating arguments with the executable and initial flags. if (iter.next()) |exe| { - try args.append(alloc, try alloc.dupeZ(u8, exe)); + try cmd.appendArg(exe); } else return null; - try args.append(alloc, "--posix"); + try cmd.appendArg("--posix"); // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile @@ -304,17 +305,17 @@ fn setupBash( if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { // All remaining arguments should be passed directly to the shell // command. We shouldn't perform any further option processing. - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); while (iter.next()) |remaining_arg| { - try args.append(alloc, try alloc.dupeZ(u8, remaining_arg)); + try cmd.appendArg(remaining_arg); } break; } else { - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } } try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); @@ -352,8 +353,11 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Join the accumulated arguments to form the final command string. - return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; + // Get the command string from the builder, then copy it to the arena + // allocator. The stackFallback allocator's memory becomes invalid after + // this function returns, so we must copy to the arena. + const cmd_str = try cmd.toOwnedSlice(); + return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; } test "bash" { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index eabfd6a4b..182770339 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -246,7 +246,7 @@ pub const StreamHandler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all),