diff --git a/.agents/commands/gh-issue b/.agents/commands/gh-issue new file mode 100755 index 000000000..de2f37335 --- /dev/null +++ b/.agents/commands/gh-issue @@ -0,0 +1,64 @@ +#!/usr/bin/env nu + +# A command to generate an agent prompt to diagnose and formulate +# a plan for resolving a GitHub issue. +# +# IMPORTANT: This command is prompted to NOT write any code and to ONLY +# produce a plan. You should still be vigilant when running this but that +# is the expected behavior. +# +# The `` parameter can be either an issue number or a full GitHub +# issue URL. +def main [ + issue: any, # Ghostty issue number or URL + --repo: string = "ghostty-org/ghostty" # GitHub repository in the format "owner/repo" +] { + # TODO: This whole script doesn't handle errors very well. I actually + # don't know Nu well enough to know the proper way to handle it all. + + let issueData = gh issue view $issue --json author,title,number,body,comments | from json + let comments = $issueData.comments | each { |comment| + $" +### Comment by ($comment.author.login) +($comment.body) +" | str trim + } | str join "\n\n" + + $" +Deep-dive on this GitHub issue. Find the problem and generate a plan. +Do not write code. Explain the problem clearly and propose a comprehensive plan +to solve it. + +# ($issueData.title) \(($issueData.number)\) + +## Description +($issueData.body) + +## Comments +($comments) + +## Your Tasks + +You are an experienced software developer tasked with diagnosing issues. + +1. Review the issue context and details. +2. Examine the relevant parts of the codebase. Analyze the code thoroughly + until you have a solid understanding of how it works. +3. Explain the issue in detail, including the problem and its root cause. +4. Create a comprehensive plan to solve the issue. The plan should include: + - Required code changes + - Potential impacts on other parts of the system + - Necessary tests to be written or updated + - Documentation updates + - Performance considerations + - Security implications + - Backwards compatibility \(if applicable\) + - Include the reference link to the source issue and any related discussions +4. Think deeply about all aspects of the task. Consider edge cases, potential + challenges, and best practices for addressing the issue. Review the plan + with the oracle and adjust it based on its feedback. + +**ONLY CREATE A PLAN. DO NOT WRITE ANY CODE.** Your task is to create +a thorough, comprehensive strategy for understanding and resolving the issue. +" | str trim +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 419e83235..a2b2a84aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,6 @@ jobs: - build-windows - test - test-gtk - - test-gtk-ng - test-sentry-linux - test-macos - pinact @@ -495,9 +494,6 @@ jobs: - name: Test GTK Build run: nix develop -c zig build -Dapp-runtime=gtk -Demit-docs -Demit-webdata - - name: Test GTK-NG Build - run: nix develop -c zig build -Dapp-runtime=gtk-ng -Demit-docs -Demit-webdata - # This relies on the cache being populated by the commands above. - name: Test System Build run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p @@ -551,55 +547,6 @@ jobs: -Dgtk-x11=${{ matrix.x11 }} \ -Dgtk-wayland=${{ matrix.wayland }} - test-gtk-ng: - strategy: - fail-fast: false - matrix: - x11: ["true", "false"] - wayland: ["true", "false"] - name: GTK x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} - runs-on: namespace-profile-ghostty-sm - needs: test - env: - ZIG_LOCAL_CACHE_DIR: /zig/local-cache - ZIG_GLOBAL_CACHE_DIR: /zig/global-cache - steps: - - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 - with: - path: | - /nix - /zig - - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Test - run: | - nix develop -c \ - zig build \ - -Dapp-runtime=gtk-ng \ - -Dgtk-x11=${{ matrix.x11 }} \ - -Dgtk-wayland=${{ matrix.wayland }} \ - test - - - name: Build - run: | - nix develop -c \ - zig build \ - -Dapp-runtime=gtk-ng \ - -Dgtk-x11=${{ matrix.x11 }} \ - -Dgtk-wayland=${{ matrix.wayland }} - test-sentry-linux: strategy: fail-fast: false @@ -1064,7 +1011,7 @@ jobs: cd $GITHUB_WORKSPACE zig build test - - name: Build GTK-NG app runtime + - name: Build GTK app runtime shell: freebsd {0} run: | cd $GITHUB_WORKSPACE diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2e90fd94e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Agent Development Guide + +A file for [guiding coding agents](https://agents.md/). + +## Commands + +- **Build:** `zig build` +- **Test (Zig):** `zig build test` +- **Test filter (Zig)**: `zig build test -Dtest-filter=` +- **Formatting (Zig)**: `zig fmt .` +- **Formatting (other)**: `prettier -w .` + +## Directory Structure + +- Shared Zig core: `src/` +- C API: `include/ghostty.h` +- macOS app: `macos/` +- GTK (Linux and FreeBSD) app: `src/apprt/gtk` + +## macOS App + +- Do not use `xcodebuild` +- Use `zig build` to build the macOS app and any shared Zig code diff --git a/CODEOWNERS b/CODEOWNERS index 770c08860..2a93ce671 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -119,7 +119,6 @@ # GTK /src/apprt/gtk/ @ghostty-org/gtk -/src/apprt/gtk-ng/ @ghostty-org/gtk /src/os/cgroup.zig @ghostty-org/gtk /src/os/flatpak.zig @ghostty-org/gtk /dist/linux/ @ghostty-org/gtk diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 777771145..de7df4b71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,11 +45,16 @@ 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 contributors 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. + Please be respectful to maintainers and disclose AI assistance. ## Quick Guide -### I'd like to contribute! +### I'd like to contribute [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. @@ -58,7 +63,7 @@ Issues that are extra friendly to new contributors are tagged with ["contributor friendly"]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22 -### I'd like to translate Ghostty to my language! +### I'd like to translate Ghostty to my language We have written a [Translator's Guide](po/README_TRANSLATORS.md) for everyone interested in contributing translations to Ghostty. @@ -67,7 +72,7 @@ and you can submit pull requests directly, although please make sure that our [Style Guide](po/README_TRANSLATORS.md#style-guide) is followed before submission. -### I have a bug! / Something isn't working! +### I have a bug! / Something isn't working 1. Search the issue tracker and discussions for similar issues. Tip: also search for [closed issues] and [discussions] — your issue might have already @@ -82,18 +87,18 @@ submission. [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed ["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage -### I have an idea for a feature! +### I have an idea for a feature Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). -### I've implemented a feature! +### I've implemented a feature 1. If there is an issue for the feature, open a pull request straight away. 2. If there is no issue, open a discussion and link to your branch. 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question! +### I have a question Open an [Q&A discussion], or join our [Discord Server] and ask away in the `#help` channel. diff --git a/HACKING.md b/HACKING.md index d79d15a4a..2d3640fca 100644 --- a/HACKING.md +++ b/HACKING.md @@ -36,7 +36,7 @@ here: | `zig build test` | Runs unit tests (accepts `-Dtest-filter=` to only run tests whose name matches the filter) | | `zig build update-translations` | Updates Ghostty's translation strings (see the [Contributor's Guide on Localizing Ghostty](po/README_CONTRIBUTORS.md)) | | `zig build dist` | Builds a source tarball | -| `zig build distcheck` | Installs and validates a source tarball | +| `zig build distcheck` | Builds and validates a source tarball | ## Extra Dependencies @@ -69,6 +69,32 @@ sudo xcode-select --switch /Applications/Xcode-beta.app > You do not need to be running on macOS 26 to build Ghostty, you can > still use Xcode 26 beta on macOS 15 stable. +## AI and Agents + +If you're using AI assistance with Ghostty, Ghostty provides an +[AGENTS.md file](https://github.com/ghostty-org/ghostty/blob/main/AGENTS.md) +read by most of the popular AI agents to help produce higher quality +results. + +We also provide commands in `.agents/commands` that have some vetted +prompts for common tasks that have been shown to produce good results. +We provide these to help reduce the amount of time a contributor has to +spend prompting the AI to get good results, and hopefully to lower the slop +produced. + +- `/gh-issue ` - Produces a prompt for diagnosing a GitHub + issue, explaining the problem, and suggesting a plan for resolving it. + Requires `gh` to be installed with read-only access to Ghostty. + +> [!WARNING] +> +> All AI assistance usage [must be disclosed](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#ai-assistance-notice) +> and we expect contributors to understand the code that is produced and +> be able to answer questions about it. If you don't understand the +> code produced, feel free to disclose that, but if it has problems, we +> 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. + ## Linting ### Prettier diff --git a/build.zig.zon b/build.zig.zon index 4c3e36b89..ba128f853 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -54,8 +54,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - .hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9b2ee604f..0bdbb9a41 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": { + "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - "hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + "hash": "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", @@ -109,10 +109,10 @@ "url": "https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz", "hash": "sha256-/8ZooxDndgfTk/PBizJxXyI9oerExNbgV5oR345rWc8=" }, - "uucode-0.0.0-ZZjBPiqdPwB-rG3ieaq3c6tMpnksWYs4_rGj2IvFGjjB": { + "uucode-0.0.0-ZZjBPl_dPwC-BPhSJLID4Hs9O0zw-vZKGXdaOBFch8c8": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/38b82297e69a3b2dc55dc8df25f3851be37f9327.tar.gz", - "hash": "sha256-MkWxYZHONRxGyUvGI5cAKi/ckGiXIBxkktCz5r+zWrk=" + "url": "https://github.com/jacobsandlund/uucode/archive/69782fbe79e06a34ee177978d3479ed5801ce0af.tar.gz", + "hash": "sha256-wTtlHjbl17xNeg67vNELNJs9lXX3wndV5+6dqZOEvbQ=" }, "vaxis-0.1.0-BWNV_FUICQAFZnTCL11TUvnUr1Y0_ZdqtXHhd51d76Rn": { "name": "vaxis", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ea4ef3dc9..c9d11db4b 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM"; + name = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst"; - hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst"; + hash = "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY="; }; } { @@ -259,11 +259,11 @@ in }; } { - name = "uucode-0.0.0-ZZjBPiqdPwB-rG3ieaq3c6tMpnksWYs4_rGj2IvFGjjB"; + name = "uucode-0.0.0-ZZjBPl_dPwC-BPhSJLID4Hs9O0zw-vZKGXdaOBFch8c8"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/38b82297e69a3b2dc55dc8df25f3851be37f9327.tar.gz"; - hash = "sha256-MkWxYZHONRxGyUvGI5cAKi/ckGiXIBxkktCz5r+zWrk="; + url = "https://github.com/jacobsandlund/uucode/archive/69782fbe79e06a34ee177978d3479ed5801ce0af.tar.gz"; + hash = "sha256-wTtlHjbl17xNeg67vNELNJs9lXX3wndV5+6dqZOEvbQ="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 203e5dcf2..2315f08d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -26,8 +26,8 @@ https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23c https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jacobsandlund/uucode/archive/38b82297e69a3b2dc55dc8df25f3851be37f9327.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst +https://github.com/jacobsandlund/uucode/archive/69782fbe79e06a34ee177978d3479ed5801ce0af.tar.gz +https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz diff --git a/dist/linux/app.desktop.in b/dist/linux/app.desktop.in index 32ba00cfd..e05c47b6e 100644 --- a/dist/linux/app.desktop.in +++ b/dist/linux/app.desktop.in @@ -4,7 +4,7 @@ Name=@NAME@ Type=Application Comment=A terminal emulator TryExec=@GHOSTTY@ -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; @@ -23,4 +23,4 @@ X-KDE-Shortcuts=Ctrl+Alt+T [Desktop Action new-window] Name=New Window -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in index 213cda78f..873f8dcf1 100644 --- a/dist/linux/dbus.service.flatpak.in +++ b/dist/linux/dbus.service.flatpak.in @@ -1,3 +1,3 @@ [D-BUS Service] Name=@APPID@ -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index df31a1abd..8758a34a2 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] Name=@APPID@ SystemdService=app-@APPID@.service -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 76ccdd3f4..17589f00f 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -8,7 +8,7 @@ Requires=dbus.socket Type=notify-reload ReloadSignal=SIGUSR2 BusName=@APPID@ -ExecStart=@GHOSTTY@ --launched-from=systemd +ExecStart=@GHOSTTY@ --gtk-single-instance=true --initial-window=false [Install] WantedBy=graphical-session.target diff --git a/flake.lock b/flake.lock index ba1adb08a..e4a5d0be8 100644 --- a/flake.lock +++ b/flake.lock @@ -112,23 +112,20 @@ }, "zon2nix": { "inputs": { - "flake-utils": [ - "flake-utils" - ], "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1756000480, - "narHash": "sha256-fR5pdcjO0II5MNdCzqvyokyuFkmff7/FyBAjUS6sMfA=", + "lastModified": 1757167408, + "narHash": "sha256-4XyJ6fmKd9wgJ7vHUQuULYy5ps2gUgkkDk/PrJb2OPY=", "owner": "jcollie", "repo": "zon2nix", - "rev": "d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60", + "rev": "dc78177e2ad28d5a407c9e783ee781bd559d7dd5", "type": "github" }, "original": { "owner": "jcollie", "repo": "zon2nix", - "rev": "d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60", + "rev": "dc78177e2ad28d5a407c9e783ee781bd559d7dd5", "type": "github" } } diff --git a/flake.nix b/flake.nix index 99f7fcb7c..dd97744b6 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ }; zon2nix = { - url = "github:jcollie/zon2nix?rev=d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60"; + url = "github:jcollie/zon2nix?rev=dc78177e2ad28d5a407c9e783ee781bd559d7dd5"; inputs = { # Don't override nixpkgs until Zig 0.15 is available in the Nix branch # we are using for "normal" builds. diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a1cab7817..b71c2af3a 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", - "sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z", + "sha256": "87a68a51eac6957d804d511ea0dd377966aa0eabd498a75e760a71ad2a07beb6" }, { "type": "archive", @@ -133,9 +133,9 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/38b82297e69a3b2dc55dc8df25f3851be37f9327.tar.gz", - "dest": "vendor/p/uucode-0.0.0-ZZjBPiqdPwB-rG3ieaq3c6tMpnksWYs4_rGj2IvFGjjB", - "sha256": "3245b16191ce351c46c94bc62397002a2fdc906897201c6492d0b3e6bfb35ab9" + "url": "https://github.com/jacobsandlund/uucode/archive/69782fbe79e06a34ee177978d3479ed5801ce0af.tar.gz", + "dest": "vendor/p/uucode-0.0.0-ZZjBPl_dPwC-BPhSJLID4Hs9O0zw-vZKGXdaOBFch8c8", + "sha256": "c13b651e36e5d7bc4d7a0ebbbcd10b349b3d9575f7c27755e7ee9da99384bdb4" }, { "type": "git", diff --git a/include/ghostty.h b/include/ghostty.h index c871dd593..7888b380c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -964,7 +964,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t, double, ghostty_input_scroll_mods_t); void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); -void ghostty_surface_ime_point(ghostty_surface_t, double*, double*); +void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 310a46d6c..f8cf95de2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -937,7 +937,7 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in TerminalController.all { for view in c.surfaceTree { - if view.uuid == uuid { + if view.id == uuid { return view } } diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index 974f1b07f..e805466a2 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -34,7 +34,7 @@ struct TerminalEntity: AppEntity { /// Returns the view associated with this entity. This may no longer exist. @MainActor var surfaceView: Ghostty.SurfaceView? { - Self.defaultQuery.all.first { $0.uuid == self.id } + Self.defaultQuery.all.first { $0.id == self.id } } @MainActor @@ -46,7 +46,7 @@ struct TerminalEntity: AppEntity { @MainActor init(_ view: Ghostty.SurfaceView) { - self.id = view.uuid + self.id = view.id self.title = view.title self.workingDirectory = view.pwd if let nsImage = ImageRenderer(content: view.screenshot()).nsImage { @@ -80,7 +80,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { @MainActor func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] { return all.filter { - identifiers.contains($0.uuid) + identifiers.contains($0.id) }.map { TerminalEntity($0) } diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index b353f6cbe..23b597591 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,7 +1,7 @@ import AppKit /// SplitTree represents a tree of views that can be divided. -struct SplitTree: Codable { +struct SplitTree { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? @@ -29,12 +29,12 @@ struct SplitTree: Codable { } /// The path to a specific node in the tree. - struct Path { + struct Path: Codable { let path: [Component] var isEmpty: Bool { path.isEmpty } - enum Component { + enum Component: Codable { case left case right } @@ -53,7 +53,7 @@ struct SplitTree: Codable { let node: Node let bounds: CGRect } - + /// Direction for spatial navigation within the split tree. enum Direction { case left @@ -127,44 +127,51 @@ extension SplitTree { root: try root.insert(view: view, at: at, direction: direction), zoomed: nil) } + /// Find a node containing a view with the specified ID. + /// - Parameter id: The ID of the view to find + /// - Returns: The node containing the view if found, nil otherwise + func find(id: ViewType.ID) -> Node? { + guard let root else { return nil } + return root.find(id: id) + } /// Remove a node from the tree. If the node being removed is part of a split, /// the sibling node takes the place of the parent split. func remove(_ target: Node) -> Self { guard let root else { return self } - + // If we're removing the root itself, return an empty tree if root == target { return .init(root: nil, zoomed: nil) } - + // Otherwise, try to remove from the tree let newRoot = root.remove(target) - + // Update zoomed if it was the removed node let newZoomed = (zoomed == target) ? nil : zoomed - + return .init(root: newRoot, zoomed: newZoomed) } /// Replace a node in the tree with a new node. func replace(node: Node, with newNode: Node) throws -> Self { guard let root else { throw SplitError.viewNotFound } - + // Get the path to the node we want to replace guard let path = root.path(to: node) else { throw SplitError.viewNotFound } - + // Replace the node let newRoot = try root.replaceNode(at: path, with: newNode) - + // Update zoomed if it was the replaced node let newZoomed = (zoomed == node) ? newNode : zoomed - + return .init(root: newRoot, zoomed: newZoomed) } - + /// Find the next view to focus based on the current focused node and direction func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { guard let root else { return nil } @@ -230,13 +237,13 @@ extension SplitTree { let newRoot = root.equalize() return .init(root: newRoot, zoomed: zoomed) } - + /// Resize a node in the tree by the given pixel amount in the specified direction. - /// + /// /// This method adjusts the split ratios of the tree to accommodate the requested resize /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. - /// The bounds parameter is used to construct the spatial tree representation which is + /// The bounds parameter is used to construct the spatial tree representation which is /// needed to calculate the current pixel dimensions. /// /// This will always reset the zoomed state. @@ -250,22 +257,22 @@ extension SplitTree { /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { guard let root else { throw SplitError.viewNotFound } - + // Find the path to the target node guard let path = root.path(to: node) else { throw SplitError.viewNotFound } - + // Determine which type of split we need to find based on resize direction let targetSplitDirection: Direction = switch direction { case .up, .down: .vertical case .left, .right: .horizontal } - + // Find the nearest parent split of the correct type by walking up the path var splitPath: Path? var splitNode: Node? - + for i in stride(from: path.path.count - 1, through: 0, by: -1) { let parentPath = Path(path: Array(path.path.prefix(i))) if let parent = root.node(at: parentPath), case .split(let split) = parent { @@ -276,29 +283,29 @@ extension SplitTree { } } } - - guard let splitPath = splitPath, + + guard let splitPath = splitPath, let splitNode = splitNode, case .split(let split) = splitNode else { throw SplitError.viewNotFound } - + // Get current spatial representation to calculate pixel dimensions let spatial = root.spatial(within: bounds.size) guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { throw SplitError.viewNotFound } - + // Calculate the new ratio based on pixel change let pixelOffset = Double(pixels) let newRatio: Double - + switch (split.direction, direction) { case (.horizontal, .left): // Moving left boundary: decrease left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) case (.horizontal, .right): - // Moving right boundary: increase left side + // Moving right boundary: increase left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) case (.vertical, .up): // Moving top boundary: decrease top side @@ -310,7 +317,7 @@ extension SplitTree { // Direction doesn't match split type - shouldn't happen due to earlier logic throw SplitError.viewNotFound } - + // Create new split with adjusted ratio let newSplit = Node.Split( direction: split.direction, @@ -318,12 +325,12 @@ extension SplitTree { left: split.left, right: split.right ) - + // Replace the split node with the new one let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) return .init(root: newRoot, zoomed: nil) } - + /// Returns the total bounds of the split hierarchy using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// Also ignores any possible padding between views. @@ -334,6 +341,60 @@ extension SplitTree { } } +// MARK: SplitTree Codable + +fileprivate enum CodingKeys: String, CodingKey { + case version + case root + case zoomed + + static let currentVersion: Int = 1 +} + +extension SplitTree: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Check version + let version = try container.decode(Int.self, forKey: .version) + guard version == CodingKeys.currentVersion else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported SplitTree version: \(version)" + ) + ) + } + + // Decode root + self.root = try container.decodeIfPresent(Node.self, forKey: .root) + + // Zoomed is encoded as its path. Get the path and then find it. + if let zoomedPath = try container.decodeIfPresent(Path.self, forKey: .zoomed), + let root = self.root { + self.zoomed = root.node(at: zoomedPath) + } else { + self.zoomed = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // Encode version + try container.encode(CodingKeys.currentVersion, forKey: .version) + + // Encode root + try container.encodeIfPresent(root, forKey: .root) + + // Zoomed is encoded as its path since its a reference type. This lets us + // map it on decode back to the correct node in root. + if let zoomed, let path = root?.path(to: zoomed) { + try container.encode(path, forKey: .zoomed) + } + } +} + // MARK: SplitTree.Node extension SplitTree.Node { @@ -342,6 +403,23 @@ extension SplitTree.Node { typealias SplitError = SplitTree.SplitError typealias Path = SplitTree.Path + /// Find a node containing a view with the specified ID. + /// - Parameter id: The ID of the view to find + /// - Returns: The node containing the view if found, nil otherwise + func find(id: ViewType.ID) -> Node? { + switch self { + case .leaf(let view): + return view.id == id ? self : nil + + case .split(let split): + if let found = split.left.find(id: id) { + return found + } + + return split.right.find(id: id) + } + } + /// Returns the node in the tree that contains the given view. func node(view: ViewType) -> Node? { switch (self) { @@ -396,20 +474,20 @@ extension SplitTree.Node { return search(self) ? Path(path: components) : nil } - + /// Returns the node at the given path from this node as root. func node(at path: Path) -> Node? { if path.isEmpty { return self } - + guard case .split(let split) = self else { return nil } - + let component = path.path[0] let remainingPath = Path(path: Array(path.path.dropFirst())) - + switch component { case .left: return split.left.node(at: remainingPath) @@ -521,12 +599,12 @@ extension SplitTree.Node { if self == target { return nil } - + switch self { case .leaf: // A leaf that isn't the target stays as is return self - + case .split(let split): // Neither child is directly the target, so we need to recursively // try to remove from both children @@ -543,7 +621,7 @@ extension SplitTree.Node { } else if newRight == nil { return newLeft } - + // Both children still exist after removal return .split(.init( direction: split.direction, @@ -562,7 +640,7 @@ extension SplitTree.Node { case .leaf: // Leaf nodes don't have a ratio to resize return self - + case .split(let split): // Create a new split with the updated ratio return .split(.init( @@ -573,7 +651,7 @@ extension SplitTree.Node { )) } } - + /// Get the leftmost leaf in this subtree func leftmostLeaf() -> ViewType { switch self { @@ -583,7 +661,7 @@ extension SplitTree.Node { return split.left.leftmostLeaf() } } - + /// Get the rightmost leaf in this subtree func rightmostLeaf() -> ViewType { switch self { @@ -593,7 +671,7 @@ extension SplitTree.Node { return split.right.rightmostLeaf() } } - + /// Equalize this node and all its children, returning a new node with splits /// adjusted so that each split's ratio is based on the relative weight /// (number of leaves) of its children. @@ -601,14 +679,14 @@ extension SplitTree.Node { let (equalizedNode, _) = equalizeWithWeight() return equalizedNode } - + /// Internal helper that equalizes and returns both the node and its weight. private func equalizeWithWeight() -> (node: Node, weight: Int) { switch self { case .leaf: // A leaf has weight 1 and doesn't change return (self, 1) - + case .split(let split): // Calculate weights based on split direction let leftWeight = split.left.weightForDirection(split.direction) @@ -629,7 +707,7 @@ extension SplitTree.Node { left: leftNode, right: rightNode ) - + return (.split(newSplit), totalWeight) } } @@ -656,12 +734,12 @@ extension SplitTree.Node { switch self { case .leaf(let view): return [(view, bounds)] - + case .split(let split): // Calculate bounds for left and right based on split direction and ratio let leftBounds: CGRect let rightBounds: CGRect - + switch split.direction { case .horizontal: // Split horizontally: left | right @@ -678,7 +756,7 @@ extension SplitTree.Node { width: bounds.width * (1 - split.ratio), height: bounds.height ) - + case .vertical: // Split vertically: top / bottom // Note: In our normalized coordinate system, Y increases upward @@ -696,13 +774,13 @@ extension SplitTree.Node { height: bounds.height * split.ratio ) } - + // Recursively calculate bounds for children return split.left.calculateViewBounds(in: leftBounds) + split.right.calculateViewBounds(in: rightBounds) } } - + /// Returns the total bounds of this subtree using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// - Returns: The total width and height needed to contain all views in this subtree @@ -710,11 +788,11 @@ extension SplitTree.Node { switch self { case .leaf(let view): return view.bounds.size - + case .split(let split): let leftBounds = split.left.viewBounds() let rightBounds = split.right.viewBounds() - + switch split.direction { case .horizontal: // Horizontal split: width is sum, height is max @@ -722,7 +800,7 @@ extension SplitTree.Node { width: leftBounds.width + rightBounds.width, height: Swift.max(leftBounds.height, rightBounds.height) ) - + case .vertical: // Vertical split: height is sum, width is max return CGSize( @@ -760,7 +838,7 @@ extension SplitTree.Node { /// // +--------+----+ /// // | C | D | /// // +--------+----+ - /// // + /// // /// // The spatial representation would have: /// // - Total dimensions: (width: 2, height: 2) /// // - Node bounds based on actual split ratios @@ -805,7 +883,7 @@ extension SplitTree.Node { /// Example: /// ``` /// // Single leaf: (1, 1) - /// // Horizontal split with 2 leaves: (2, 1) + /// // Horizontal split with 2 leaves: (2, 1) /// // Vertical split with 2 leaves: (1, 2) /// // Complex layout with both: (2, 2) or larger /// ``` @@ -846,7 +924,7 @@ extension SplitTree.Node { /// /// The calculation process: /// 1. **Leaf nodes**: Create a single slot with the provided bounds - /// 2. **Split nodes**: + /// 2. **Split nodes**: /// - Divide the bounds according to the split ratio and direction /// - Create a slot for the split node itself /// - Recursively calculate slots for both children @@ -926,7 +1004,7 @@ extension SplitTree.Spatial { /// /// This method finds all slots positioned in the given direction from the reference node: /// - **Left**: Slots with bounds to the left of the reference node - /// - **Right**: Slots with bounds to the right of the reference node + /// - **Right**: Slots with bounds to the right of the reference node /// - **Up**: Slots with bounds above the reference node (Y=0 is top) /// - **Down**: Slots with bounds below the reference node /// @@ -955,41 +1033,41 @@ extension SplitTree.Spatial { let dy = rect2.minY - rect1.minY return sqrt(dx * dx + dy * dy) } - + let result = switch direction { case .left: // Slots to the left: their right edge is at or left of reference's left edge slots.filter { - $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .right: // Slots to the right: their left edge is at or right of reference's right edge slots.filter { - $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .up: // Slots above: their bottom edge is at or above reference's top edge slots.filter { - $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .down: // Slots below: their top edge is at or below reference's bottom edge slots.filter { - $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } } - + return result } @@ -1008,14 +1086,14 @@ extension SplitTree.Spatial { func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { // Find the slot for this node guard let slot = slots.first(where: { $0.node == node }) else { return false } - + // Calculate the overall bounds of all slots let overallBounds = slots.reduce(CGRect.null) { result, slot in result.union(slot.bounds) } - + return switch side { - case .up: + case .up: slot.bounds.minY == overallBounds.minY case .down: slot.bounds.maxY == overallBounds.maxY @@ -1052,10 +1130,10 @@ extension SplitTree.Node { case view case split } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + if container.contains(.view) { let view = try container.decode(ViewType.self, forKey: .view) self = .leaf(view: view) @@ -1071,14 +1149,14 @@ extension SplitTree.Node { ) } } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + switch self { case .leaf(let view): try container.encode(view, forKey: .view) - + case .split(let split): try container.encode(split, forKey: .split) } @@ -1093,7 +1171,7 @@ extension SplitTree.Node { switch self { case .leaf(let view): return [view] - + case .split(let split): return split.left.leaves() + split.right.leaves() } @@ -1145,7 +1223,7 @@ extension SplitTree.Node { var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } - + /// A hashable representation of a node that captures its structural identity. /// /// This type provides a way to track changes to a node's structure in SwiftUI @@ -1159,20 +1237,20 @@ extension SplitTree.Node { /// for unchanged portions of the tree. struct StructuralIdentity: Hashable { private let node: SplitTree.Node - + init(_ node: SplitTree.Node) { self.node = node } - + static func == (lhs: Self, rhs: Self) -> Bool { lhs.node.isStructurallyEqual(to: rhs.node) } - + func hash(into hasher: inout Hasher) { node.hashStructure(into: &hasher) } } - + /// Checks if this node is structurally equal to another node. /// Two nodes are structurally equal if they have the same tree structure /// and the same views (by identity) in the same positions. @@ -1181,26 +1259,26 @@ extension SplitTree.Node { case let (.leaf(view1), .leaf(view2)): // Views must be the same instance return view1 === view2 - + case let (.split(split1), .split(split2)): // Splits must have same direction and structurally equal children // Note: We intentionally don't compare ratios as they may change slightly return split1.direction == split2.direction && split1.left.isStructurallyEqual(to: split2.left) && split1.right.isStructurallyEqual(to: split2.right) - + default: // Different node types return false } } - + /// Hash keys for structural identity private enum HashKey: UInt8 { case leaf = 0 case split = 1 } - + /// Hashes the structural identity of this node. /// Includes the tree structure and view identities in the hash. fileprivate func hashStructure(into hasher: inout Hasher) { @@ -1208,7 +1286,7 @@ extension SplitTree.Node { case .leaf(let view): hasher.combine(HashKey.leaf) hasher.combine(ObjectIdentifier(view)) - + case .split(let split): hasher.combine(HashKey.split) hasher.combine(split.direction) @@ -1247,17 +1325,17 @@ extension SplitTree { struct StructuralIdentity: Hashable { private let root: Node? private let zoomed: Node? - + init(_ tree: SplitTree) { self.root = tree.root self.zoomed = tree.zoomed } - + static func == (lhs: Self, rhs: Self) -> Bool { areNodesStructurallyEqual(lhs.root, rhs.root) && areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) } - + func hash(into hasher: inout Hasher) { hasher.combine(0) // Tree marker if let root = root { @@ -1268,7 +1346,7 @@ extension SplitTree { zoomed.hashStructure(into: &hasher) } } - + /// Helper to compare optional nodes for structural equality private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { switch (lhs, rhs) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 414f38d81..bdf3abeb6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -439,8 +439,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr continue } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { + if let equiv = ghostty.config.keyboardShortcut(for: "goto_tab:\(tab)") { window.keyEquivalent = "\(equiv)" } else { window.keyEquivalent = "" @@ -861,7 +860,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, - let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) { DispatchQueue.main.async { Ghostty.moveFocus(to: focusTarget, from: nil) } @@ -876,7 +875,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return .init( frame: window.frame, surfaceTree: surfaceTree, - focusedSurface: focusedSurface?.uuid, + focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup) } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 8baa76246..1e640967e 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,13 +4,13 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 4 + static let version: Int = 5 let focusedSurface: String? let surfaceTree: SplitTree init(from controller: TerminalController) { - self.focusedSurface = controller.focusedSurface?.uuid.uuidString + self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree } @@ -96,7 +96,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? for view in c.surfaceTree { - if view.uuid.uuidString == focusedStr { + if view.id.uuidString == focusedStr { foundView = view break } diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index 996506f0b..dc7dd7633 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -31,9 +31,16 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { .closable, .miniaturizable, ] - + /// Apply the hidden titlebar style. private func reapplyHiddenStyle() { + // If our window is fullscreen then we don't reapply the hidden style because + // it can result in messing up non-native fullscreen. See: + // https://github.com/ghostty-org/ghostty/issues/8415 + if terminalController?.fullscreenStyle?.isFullscreen ?? false { + return + } + // Apply our style mask while preserving the .fullScreen option if styleMask.contains(.fullScreen) { styleMask = Self.hiddenStyleMask.union([.fullScreen]) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2d83a8a6b..1c5c8eb6a 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -6,9 +6,11 @@ import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. - class SurfaceView: OSView, ObservableObject, Codable { + class SurfaceView: OSView, ObservableObject, Codable, Identifiable { + typealias ID = UUID + /// Unique ID per surface - let uuid: UUID + let id: UUID // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go @@ -180,7 +182,7 @@ extension Ghostty { init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() - self.uuid = uuid ?? .init() + self.id = uuid ?? .init() // Our initial config always is our application wide config. if let appDelegate = NSApplication.shared.delegate as? AppDelegate { @@ -1264,7 +1266,7 @@ extension Ghostty { var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing - + // For text, we only encode UTF8 if we don't have a single control // character. Control characters are encoded by Ghostty itself. // Without this, `ctrl+enter` does the wrong thing. @@ -1468,7 +1470,7 @@ extension Ghostty { content.body = body content.sound = UNNotificationSound.default content.categoryIdentifier = Ghostty.userNotificationCategory - content.userInfo = ["surface": self.uuid.uuidString] + content.userInfo = ["surface": self.id.uuidString] let uuid = UUID().uuidString let request = UNNotificationRequest( @@ -1576,7 +1578,7 @@ extension Ghostty { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(pwd, forKey: .pwd) - try container.encode(uuid.uuidString, forKey: .uuid) + try container.encode(id.uuidString, forKey: .uuid) try container.encode(title, forKey: .title) try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle) } @@ -1683,8 +1685,10 @@ extension Ghostty.SurfaceView: NSTextInputClient { } // Ghostty will tell us where it thinks an IME keyboard should render. - var x: Double = 0; - var y: Double = 0; + var x: Double = 0 + var y: Double = 0 + var width: Double = cellSize.width + var height: Double = cellSize.height // QuickLook never gives us a matching range to our selection so if we detect // this then we return the top-left selection point rather than the cursor point. @@ -1702,15 +1706,19 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Free our text ghostty_surface_free_text(surface, &text) } else { - ghostty_surface_ime_point(surface, &x, &y) + ghostty_surface_ime_point(surface, &x, &y, &width, &height) } } else { - ghostty_surface_ime_point(surface, &x, &y) + ghostty_surface_ime_point(surface, &x, &y, &width, &height) } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects - let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0) + let viewRect = NSMakeRect( + x, + frame.size.height - y, + max(width, cellSize.width), + max(height, cellSize.height)) // Convert the point to the window coordinates let winRect = self.convert(viewRect, to: nil) diff --git a/nix/devShell.nix b/nix/devShell.nix index 7b78539f7..783d6018d 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -3,6 +3,7 @@ lib, stdenv, bashInteractive, + nushell, appstream, flatpak-builder, gdb, @@ -60,6 +61,7 @@ pandoc, pinact, hyperfine, + poop, typos, shellcheck, uv, @@ -124,6 +126,9 @@ in # CI uv + # Scripting + nushell + # We need these GTK-related deps on all platform so we can build # dist tarballs. blueprint-compiler @@ -183,6 +188,9 @@ in # developer shell glycin-loaders librsvg + + # for benchmarking + poop ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 272b27dc2..aa036b38c 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -3,15 +3,16 @@ # Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # blackzeshi , 2025. -# +# Ivan Bastrakov , 2025. + msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-03-24 00:01+0500\n" -"Last-Translator: blackzeshi \n" "Language-Team: Russian \n" +"PO-Revision-Date: 2025-09-03 01:50+0300\n" +"Last-Translator: Ivan Bastrakov \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -89,7 +90,7 @@ msgstr "Сплит вправо" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "Выполнить команду…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -162,7 +163,7 @@ msgstr "Открыть конфигурационный файл" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "Палитра команд" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -210,12 +211,12 @@ msgstr "Разрешить" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "Запомнить выбор для этого сплита" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "Перезагрузите конфигурацию, чтобы снова увидеть это сообщение" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -223,7 +224,8 @@ msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -"Приложение пытается записать данные в буфер обмена. Эти данные показаны ниже." +"Приложение пытается записать данные в буфер обмена. Текущее содержимое " +"буфера обмена показано ниже." #: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" @@ -279,15 +281,15 @@ msgstr "Скопировано в буфер обмена" #: src/apprt/gtk/Surface.zig:1268 msgid "Cleared clipboard" -msgstr "" +msgstr "Буфер обмена очищен" #: src/apprt/gtk/Surface.zig:2525 msgid "Command succeeded" -msgstr "" +msgstr "Команда выполнена успешно" #: src/apprt/gtk/Surface.zig:2527 msgid "Command failed" -msgstr "" +msgstr "Команда завершилась с ошибкой" #: src/apprt/gtk/Window.zig:216 msgid "Main Menu" @@ -299,7 +301,7 @@ msgstr "Просмотреть открытые вкладки" #: src/apprt/gtk/Window.zig:266 msgid "New Split" -msgstr "" +msgstr "Новый сплит" #: src/apprt/gtk/Window.zig:329 msgid "" diff --git a/src/Surface.zig b/src/Surface.zig index 330d25102..bfadb3be8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -258,6 +258,7 @@ const DerivedConfig = struct { mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?configpkg.OptionAsAlt, + selection_clear_on_copy: bool, selection_clear_on_typing: bool, vt_kam_allowed: bool, wait_after_command: bool, @@ -327,6 +328,7 @@ const DerivedConfig = struct { .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", + .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", @@ -1730,6 +1732,7 @@ pub fn pwd( pub fn imePoint(self: *const Surface) apprt.IMEPos { self.renderer_state.mutex.lock(); const cursor = self.renderer_state.terminal.screen.cursor; + const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; self.renderer_state.mutex.unlock(); // TODO: need to handle when scrolling and the cursor is not @@ -1764,7 +1767,38 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { break :y y; }; - return .{ .x = x, .y = y }; + // Our height for now is always just the cell height because our preedit + // rendering only renders in a single line. + const height: f64 = height: { + var height: f64 = @floatFromInt(self.size.cell.height); + height /= content_scale.y; + break :height height; + }; + const width: f64 = width: { + var width: f64 = @floatFromInt(preedit_width * self.size.cell.width); + + // Our max width is the remaining screen width after the cursor. + // We don't have to deal with wrapping because the preedit doesn't + // wrap right now. + const screen_width: f64 = @floatFromInt(self.size.terminal().width); + const x_offset: f64 = @floatFromInt((cursor.x + 1) * self.size.cell.width); + const max = screen_width - x_offset; + width = @min(width, max); + + // Note: we don't apply content scale here because it looks like + // for some reason in macOS its already scaled. I'm not sure why + // that is so I'm going to just leave this comment here so its known + // that I left this out on purpose pending more investigation. + + break :width width; + }; + + return .{ + .x = x, + .y = y, + .width = width, + .height = height, + }; } fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { @@ -4512,6 +4546,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return true; }; + // Clear the selection if configured to do so. + if (self.config.selection_clear_on_copy) { + if (self.setSelection(null)) { + self.queueRender() catch |err| { + log.warn("failed to queue render after clear selection err={}", .{err}); + }; + } else |err| { + log.warn("failed to clear selection after copy err={}", .{err}); + } + } + return true; } diff --git a/src/apprt.zig b/src/apprt.zig index 2e3a722a6..ccb1251a2 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -17,7 +17,6 @@ const structs = @import("apprt/structs.zig"); pub const action = @import("apprt/action.zig"); pub const ipc = @import("apprt/ipc.zig"); pub const gtk = @import("apprt/gtk.zig"); -pub const gtk_ng = @import("apprt/gtk-ng.zig"); pub const none = @import("apprt/none.zig"); pub const browser = @import("apprt/browser.zig"); pub const embedded = @import("apprt/embedded.zig"); @@ -44,7 +43,6 @@ pub const runtime = switch (build_config.artifact) { .exe => switch (build_config.app_runtime) { .none => none, .gtk => gtk, - .@"gtk-ng" => gtk_ng, }, .lib => embedded, .wasm_module => browser, @@ -62,18 +60,13 @@ pub const Runtime = enum { /// GTK4. Rich windowed application. This uses a full GObject-based /// approach to building the application. - @"gtk-ng", - - /// GTK-backed. Rich windowed application. GTK is dynamically linked. - /// WARNING: Deprecated. This will be removed very soon. All bug fixes - /// and features should go into the gtk-ng backend. gtk, pub fn default(target: std.Target) Runtime { return switch (target.os.tag) { // The Linux and FreeBSD default is GTK because it is a full // featured application. - .linux, .freebsd => .@"gtk-ng", + .linux, .freebsd => .gtk, // Otherwise, we do NONE so we don't create an exe and we create // libghostty. On macOS, Xcode is used to build the app that links // to libghostty. diff --git a/src/apprt/action.zig b/src/apprt/action.zig index a41a4627f..fbcc92805 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -542,7 +542,7 @@ pub const InitialSize = extern struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( InitialSize, .{ .name = "GhosttyApprtInitialSize" }, ), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e4961ac49..08d8291ef 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -909,10 +909,7 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - switch (self.app.config.@"launched-from".?) { - .desktop => env.remove("LANGUAGE"), - .dbus, .systemd, .cli => {}, - } + if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); } return env; @@ -1822,10 +1819,18 @@ pub const CAPI = struct { surface.mousePressureCallback(stage, pressure); } - export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void { + export fn ghostty_surface_ime_point( + surface: *Surface, + x: *f64, + y: *f64, + width: *f64, + height: *f64, + ) void { const pos = surface.core_surface.imePoint(); x.* = pos.x; y.* = pos.y; + width.* = pos.width; + height.* = pos.height; } /// Request that the surface become closed. This will go through the diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig deleted file mode 100644 index fe1bac023..000000000 --- a/src/apprt/gtk-ng.zig +++ /dev/null @@ -1,15 +0,0 @@ -const internal_os = @import("../os/main.zig"); - -// The required comptime API for any apprt. -pub const App = @import("gtk-ng/App.zig"); -pub const Surface = @import("gtk-ng/Surface.zig"); -pub const resourcesDir = internal_os.resourcesDir; - -// The exported API, custom for the apprt. -pub const class = @import("gtk-ng/class.zig"); -pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; - -test { - @import("std").testing.refAllDecls(@This()); - _ = @import("gtk-ng/ext.zig"); -} diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig deleted file mode 100644 index 4d2006fbb..000000000 --- a/src/apprt/gtk-ng/App.zig +++ /dev/null @@ -1,104 +0,0 @@ -/// This is the main entrypoint to the apprt for Ghostty. Ghostty will -/// initialize this in main to start the application.. -const App = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const adw = @import("adw"); -const gio = @import("gio"); -const apprt = @import("../../apprt.zig"); -const configpkg = @import("../../config.zig"); -const internal_os = @import("../../os/main.zig"); -const Config = configpkg.Config; -const CoreApp = @import("../../App.zig"); - -const Application = @import("class/application.zig").Application; -const Surface = @import("Surface.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); -const ipcNewWindow = @import("ipc/new_window.zig").newWindow; - -const log = std.log.scoped(.gtk); - -/// This is detected by the Renderer, in which case it sends a `redraw_surface` -/// message so that we can call `drawFrame` ourselves from the app thread, -/// because GTK's `GLArea` does not support drawing from a different thread. -pub const must_draw_from_app_thread = true; - -/// GTK application ID -pub const application_id = switch (builtin.mode) { - .Debug, .ReleaseSafe => "com.mitchellh.ghostty-debug", - .ReleaseFast, .ReleaseSmall => "com.mitchellh.ghostty", -}; - -/// GTK object path -pub const object_path = switch (builtin.mode) { - .Debug, .ReleaseSafe => "/com/mitchellh/ghostty_debug", - .ReleaseFast, .ReleaseSmall => "/com/mitchellh/ghostty", -}; - -/// The GObject Application instance -app: *Application, - -pub fn init( - self: *App, - core_app: *CoreApp, - - // Required by the apprt interface but we don't use it. - opts: struct {}, -) !void { - _ = opts; - - const app: *Application = try .new(self, core_app); - errdefer app.unref(); - self.* = .{ .app = app }; - return; -} - -pub fn run(self: *App) !void { - try self.app.run(); -} - -pub fn terminate(self: *App) void { - // We force deinitialize the app. We don't unref because other things - // tend to have a reference at this point, so this just forces the - // disposal now. - self.app.deinit(); -} - -/// Called by CoreApp to wake up the event loop. -pub fn wakeup(self: *App) void { - self.app.wakeup(); -} - -pub fn performAction( - self: *App, - target: apprt.Target, - comptime action: apprt.Action.Key, - value: apprt.Action.Value(action), -) !bool { - return try self.app.performAction(target, action, value); -} - -/// Send the given IPC to a running Ghostty. Returns `true` if the action was -/// able to be performed, `false` otherwise. -/// -/// Note that this is a static function. Since this is called from a CLI app (or -/// some other process that is not Ghostty) there is no full-featured apprt App -/// to use. -pub fn performIpc( - alloc: Allocator, - target: apprt.ipc.Target, - comptime action: apprt.ipc.Action.Key, - value: apprt.ipc.Action.Value(action), -) !bool { - switch (action) { - .new_window => return try ipcNewWindow(alloc, target, value), - } -} - -/// Redraw the inspector for the given surface. -pub fn redrawInspector(_: *App, surface: *Surface) void { - surface.redrawInspector(); -} diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig deleted file mode 100644 index ac82f941b..000000000 --- a/src/apprt/gtk-ng/Surface.zig +++ /dev/null @@ -1,103 +0,0 @@ -const Self = @This(); - -const std = @import("std"); -const apprt = @import("../../apprt.zig"); -const CoreSurface = @import("../../Surface.zig"); -const ApprtApp = @import("App.zig"); -const Application = @import("class/application.zig").Application; -const Surface = @import("class/surface.zig").Surface; - -/// The GObject Surface -surface: *Surface, - -pub fn deinit(self: *Self) void { - _ = self; -} - -/// Returns the GObject surface for this apprt surface. This is a function -/// so we can add some extra logic if we ever have to here. -pub fn gobj(self: *Self) *Surface { - return self.surface; -} - -pub fn core(self: *Self) *CoreSurface { - // This asserts the non-optional because libghostty should only - // be calling this for initialized surfaces. - return self.surface.core().?; -} - -pub fn rtApp(self: *Self) *ApprtApp { - _ = self; - return Application.default().rt(); -} - -pub fn close(self: *Self, process_active: bool) void { - _ = process_active; - self.surface.close(); -} - -pub fn cgroup(self: *Self) ?[]const u8 { - return self.surface.cgroupPath(); -} - -pub fn getTitle(self: *Self) ?[:0]const u8 { - return self.surface.getTitle(); -} - -pub fn getContentScale(self: *const Self) !apprt.ContentScale { - return self.surface.getContentScale(); -} - -pub fn getSize(self: *const Self) !apprt.SurfaceSize { - return self.surface.getSize(); -} - -pub fn getCursorPos(self: *const Self) !apprt.CursorPos { - return self.surface.getCursorPos(); -} - -pub fn supportsClipboard( - self: *const Self, - clipboard_type: apprt.Clipboard, -) bool { - _ = self; - return switch (clipboard_type) { - .standard, - .selection, - .primary, - => true, - }; -} - -pub fn clipboardRequest( - self: *Self, - clipboard_type: apprt.Clipboard, - state: apprt.ClipboardRequest, -) !void { - try self.surface.clipboardRequest( - clipboard_type, - state, - ); -} - -pub fn setClipboardString( - self: *Self, - val: [:0]const u8, - clipboard_type: apprt.Clipboard, - confirm: bool, -) !void { - self.surface.setClipboardString( - val, - clipboard_type, - confirm, - ); -} - -pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { - return try self.surface.defaultTermioEnv(); -} - -/// Redraw the inspector for our surface. -pub fn redrawInspector(self: *Self) void { - self.surface.redrawInspector(); -} diff --git a/src/apprt/gtk-ng/adw_version.zig b/src/apprt/gtk-ng/adw_version.zig deleted file mode 100644 index 7ce88f585..000000000 --- a/src/apprt/gtk-ng/adw_version.zig +++ /dev/null @@ -1,122 +0,0 @@ -const std = @import("std"); - -// Until the gobject bindings are built at the same time we are building -// Ghostty, we need to import `adwaita.h` directly to ensure that the version -// macros match the version of `libadwaita` that we are building/linking -// against. -const c = @cImport({ - @cInclude("adwaita.h"); -}); - -const adw = @import("adw"); - -const log = std.log.scoped(.gtk); - -pub const comptime_version: std.SemanticVersion = .{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, -}; - -pub fn getRuntimeVersion() std.SemanticVersion { - return .{ - .major = adw.getMajorVersion(), - .minor = adw.getMinorVersion(), - .patch = adw.getMicroVersion(), - }; -} - -pub fn logVersion() void { - log.info("libadwaita version build={} runtime={}", .{ - comptime_version, - getRuntimeVersion(), - }); -} - -/// Verifies that the running libadwaita version is at least the given -/// version. This will return false if Ghostty is configured to not build with -/// libadwaita. -/// -/// This can be run in both a comptime and runtime context. If it is run in a -/// comptime context, it will only check the version in the headers. If it is -/// run in a runtime context, it will check the actual version of the library we -/// are linked against. So generally you probably want to do both checks! -/// -/// This is inlined so that the comptime checks will disable the runtime checks -/// if the comptime checks fail. -pub inline fn atLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // If our header has lower versions than the given version, we can return - // false immediately. This prevents us from compiling against unknown - // symbols and makes runtime checks very slightly faster. - if (comptime comptime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt) return false; - - // If we're in comptime then we can't check the runtime version. - if (@inComptime()) return true; - - return runtimeAtLeast(major, minor, micro); -} - -/// Verifies that the libadwaita version at runtime is at least the given version. -/// -/// This function should be used in cases where the only the runtime behavior -/// is affected by the version check. For checks which would affect code -/// generation, use `atLeast`. -pub inline fn runtimeAtLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // We use the functions instead of the constants such as c.GTK_MINOR_VERSION - // because the function gets the actual runtime version. - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) != .lt; -} - -test "versionAtLeast" { - const testing = std.testing; - - const funs = &.{ atLeast, runtimeAtLeast }; - inline for (funs) |fun| { - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); - } -} - -// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+) -pub inline fn supportsDialogs() bool { - return atLeast(1, 5, 0); -} - -pub inline fn supportsTabOverview() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsSwitchRow() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsToolbarView() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsBanner() bool { - return atLeast(1, 3, 0); -} diff --git a/src/apprt/gtk-ng/cgroup.zig b/src/apprt/gtk-ng/cgroup.zig deleted file mode 100644 index 23c4d545e..000000000 --- a/src/apprt/gtk-ng/cgroup.zig +++ /dev/null @@ -1,213 +0,0 @@ -/// Contains all the logic for putting the Ghostty process and -/// each individual surface into its own cgroup. -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); - -const App = @import("App.zig"); -const internal_os = @import("../../os/main.zig"); - -const log = std.log.scoped(.gtk_systemd_cgroup); - -pub const Options = struct { - memory_high: ?u64 = null, - pids_max: ?u64 = null, -}; - -/// Initialize the cgroup for the app. This will create our -/// transient scope, initialize the cgroups we use for the app, -/// configure them, and return the cgroup path for the app. -/// -/// Returns the path of the current cgroup for the app, which is -/// allocated with the given allocator. -pub fn init( - alloc: Allocator, - dbus: *gio.DBusConnection, - opts: Options, -) ![]const u8 { - const pid = std.os.linux.getpid(); - - // Get our initial cgroup. We need this so we can compare - // and detect when we've switched to our transient group. - const original = try internal_os.cgroup.current( - alloc, - pid, - ) orelse ""; - defer alloc.free(original); - - // Create our transient scope. If this succeeds then the unit - // was created, but we may not have moved into it yet, so we need - // to do a dumb busy loop to wait for the move to complete. - try createScope(dbus, pid); - const transient = transient: while (true) { - const current = try internal_os.cgroup.current( - alloc, - pid, - ) orelse ""; - if (!std.mem.eql(u8, original, current)) break :transient current; - alloc.free(current); - std.time.sleep(25 * std.time.ns_per_ms); - }; - errdefer alloc.free(transient); - log.info("transient scope created cgroup={s}", .{transient}); - - // Create the app cgroup and put ourselves in it. This is - // required because controllers can't be configured while a - // process is in a cgroup. - try internal_os.cgroup.create(transient, "app", pid); - - // Create a cgroup that will contain all our surfaces. We will - // enable the controllers and configure resource limits for surfaces - // only on this cgroup so that it doesn't affect our main app. - try internal_os.cgroup.create(transient, "surfaces", null); - const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient}); - defer alloc.free(surfaces); - - // Enable all of our cgroup controllers. If these fail then - // we just log. We can't reasonably undo what we've done above - // so we log the warning and still return the transient group. - // I don't know a scenario where this fails yet. - try enableControllers(alloc, transient); - try enableControllers(alloc, surfaces); - - // Configure the "high" memory limit. This limit is used instead - // of "max" because it's a soft limit that can be exceeded and - // can be monitored by things like systemd-oomd to kill if needed, - // versus an instant hard kill. - if (opts.memory_high) |limit| { - try internal_os.cgroup.configureLimit(surfaces, .{ - .memory_high = limit, - }); - } - - // Configure the "max" pids limit. This is a hard limit and cannot be - // exceeded. - if (opts.pids_max) |limit| { - try internal_os.cgroup.configureLimit(surfaces, .{ - .pids_max = limit, - }); - } - - return transient; -} - -/// Enable all the cgroup controllers for the given cgroup. -fn enableControllers(alloc: Allocator, cgroup: []const u8) !void { - const raw = try internal_os.cgroup.controllers(alloc, cgroup); - defer alloc.free(raw); - - // Build our string builder for enabling all controllers - var builder = std.ArrayList(u8).init(alloc); - defer builder.deinit(); - - // Controllers are space-separated - var it = std.mem.splitScalar(u8, raw, ' '); - while (it.next()) |controller| { - try builder.append('+'); - try builder.appendSlice(controller); - if (it.rest().len > 0) try builder.append(' '); - } - - // Enable them all - try internal_os.cgroup.configureControllers( - cgroup, - builder.items, - ); -} - -/// Create a transient systemd scope unit for the current process and -/// move our process into it. -fn createScope( - dbus: *gio.DBusConnection, - pid_: std.os.linux.pid_t, -) !void { - const pid: u32 = @intCast(pid_); - - // The unit name needs to be unique. We use the pid for this. - var name_buf: [256]u8 = undefined; - const name = std.fmt.bufPrintZ( - &name_buf, - "app-ghostty-transient-{}.scope", - .{pid}, - ) catch unreachable; - - const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))"); - defer glib.free(builder_type); - - // Initialize our builder to build up our parameters - var builder: glib.VariantBuilder = undefined; - builder.init(builder_type); - - builder.add("s", name.ptr); - builder.add("s", "fail"); - - { - // Properties - const properties_type = glib.VariantType.new("a(sv)"); - defer glib.free(properties_type); - - builder.open(properties_type); - defer builder.close(); - - // https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html - const pressure_value = glib.Variant.newString("kill"); - - builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value); - - // Delegate - const delegate_value = glib.Variant.newBoolean(1); - builder.add("(sv)", "Delegate", delegate_value); - - // Pid to move into the unit - const pids_value_type = glib.VariantType.new("u"); - defer glib.free(pids_value_type); - - const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32)); - - builder.add("(sv)", "PIDs", pids_value); - } - - { - // Aux - const aux_type = glib.VariantType.new("a(sa(sv))"); - defer glib.free(aux_type); - - builder.open(aux_type); - defer builder.close(); - } - - var err: ?*glib.Error = null; - defer if (err) |e| e.free(); - - const reply_type = glib.VariantType.new("(o)"); - defer glib.free(reply_type); - - const value = builder.end(); - - const reply = dbus.callSync( - "org.freedesktop.systemd1", - "/org/freedesktop/systemd1", - "org.freedesktop.systemd1.Manager", - "StartTransientUnit", - value, - reply_type, - .{}, - -1, - null, - &err, - ) orelse { - if (err) |e| log.err( - "creating transient cgroup scope failed code={} err={s}", - .{ - e.f_code, - if (e.f_message) |msg| msg else "(no message)", - }, - ); - return error.DbusCallFailed; - }; - defer reply.unref(); -} diff --git a/src/apprt/gtk-ng/gtk_version.zig b/src/apprt/gtk-ng/gtk_version.zig deleted file mode 100644 index 6f3d733a5..000000000 --- a/src/apprt/gtk-ng/gtk_version.zig +++ /dev/null @@ -1,140 +0,0 @@ -const std = @import("std"); - -// Until the gobject bindings are built at the same time we are building -// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version -// macros match the version of `gtk4` that we are building/linking against. -const c = @cImport({ - @cInclude("gtk/gtk.h"); -}); - -const gtk = @import("gtk"); - -const log = std.log.scoped(.gtk); - -pub const comptime_version: std.SemanticVersion = .{ - .major = c.GTK_MAJOR_VERSION, - .minor = c.GTK_MINOR_VERSION, - .patch = c.GTK_MICRO_VERSION, -}; - -pub fn getRuntimeVersion() std.SemanticVersion { - return .{ - .major = gtk.getMajorVersion(), - .minor = gtk.getMinorVersion(), - .patch = gtk.getMicroVersion(), - }; -} - -pub fn logVersion() void { - log.info("GTK version build={} runtime={}", .{ - comptime_version, - getRuntimeVersion(), - }); -} - -/// Verifies that the GTK version is at least the given version. -/// -/// This can be run in both a comptime and runtime context. If it is run in a -/// comptime context, it will only check the version in the headers. If it is -/// run in a runtime context, it will check the actual version of the library we -/// are linked against. -/// -/// This function should be used in cases where the version check would affect -/// code generation, such as using symbols that are only available beyond a -/// certain version. For checks which only depend on GTK's runtime behavior, -/// use `runtimeAtLeast`. -/// -/// This is inlined so that the comptime checks will disable the runtime checks -/// if the comptime checks fail. -pub inline fn atLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // If our header has lower versions than the given version, - // we can return false immediately. This prevents us from - // compiling against unknown symbols and makes runtime checks - // very slightly faster. - if (comptime comptime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt) return false; - - // If we're in comptime then we can't check the runtime version. - if (@inComptime()) return true; - - return runtimeAtLeast(major, minor, micro); -} - -/// Verifies that the GTK version at runtime is at least the given version. -/// -/// This function should be used in cases where the only the runtime behavior -/// is affected by the version check. For checks which would affect code -/// generation, use `atLeast`. -pub inline fn runtimeAtLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // We use the functions instead of the constants such as c.GTK_MINOR_VERSION - // because the function gets the actual runtime version. - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) != .lt; -} - -pub inline fn runtimeUntil( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt; -} - -test "atLeast" { - const testing = std.testing; - - const funs = &.{ atLeast, runtimeAtLeast }; - inline for (funs) |fun| { - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); - } -} - -test "runtimeUntil" { - const testing = std.testing; - - // This is an array in case we add a comptime variant. - const funs = &.{runtimeUntil}; - inline for (funs) |fun| { - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); - } -} diff --git a/src/apprt/gtk-ng/ipc/new_window.zig b/src/apprt/gtk-ng/ipc/new_window.zig deleted file mode 100644 index 55e2e0e01..000000000 --- a/src/apprt/gtk-ng/ipc/new_window.zig +++ /dev/null @@ -1,62 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const glib = @import("glib"); - -const apprt = @import("../../../apprt.zig"); -const DBus = @import("DBus.zig"); - -// Use a D-Bus method call to open a new window on GTK. -// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI -// -// `ghostty +new-window` is equivalent to the following command (on a release build): -// -// ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] [] -// ``` -// -// `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): -// -// ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] -// ``` -pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { - var dbus = try DBus.init( - alloc, - target, - if (value.arguments == null) - "new-window" - else - "new-window-command", - ); - defer dbus.deinit(alloc); - - if (value.arguments) |arguments| { - // If `-e` was specified on the command line, the first - // parameter is an array of strings that contain the arguments - // that came after `-e`, which will be interpreted as a command - // to run. - const as_variant_type = glib.VariantType.new("as"); - defer as_variant_type.free(); - - const s_variant_type = glib.VariantType.new("s"); - defer s_variant_type.free(); - - var command: glib.VariantBuilder = undefined; - command.init(as_variant_type); - errdefer command.clear(); - - for (arguments) |argument| { - const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); - defer bytes.unref(); - const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); - command.addValue(string); - } - - dbus.addParameter(command.end()); - } - - try dbus.send(); - - return true; -} diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk-ng/key.zig deleted file mode 100644 index a00b0312e..000000000 --- a/src/apprt/gtk-ng/key.zig +++ /dev/null @@ -1,411 +0,0 @@ -const std = @import("std"); -const build_options = @import("build_options"); - -const gdk = @import("gdk"); -const glib = @import("glib"); -const gtk = @import("gtk"); - -const input = @import("../../input.zig"); -const winproto = @import("winproto.zig"); - -/// Returns a GTK accelerator string from a trigger. -pub fn accelFromTrigger( - buf: []u8, - trigger: input.Binding.Trigger, -) error{NoSpaceLeft}!?[:0]const u8 { - var buf_stream = std.io.fixedBufferStream(buf); - const writer = buf_stream.writer(); - - // Modifiers - if (trigger.mods.shift) try writer.writeAll(""); - if (trigger.mods.ctrl) try writer.writeAll(""); - if (trigger.mods.alt) try writer.writeAll(""); - if (trigger.mods.super) try writer.writeAll(""); - - // Write our key - if (!try writeTriggerKey(writer, trigger)) return null; - - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; -} - -/// Returns a XDG-compliant shortcuts string from a trigger. -/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ -pub fn xdgShortcutFromTrigger( - buf: []u8, - trigger: input.Binding.Trigger, -) error{NoSpaceLeft}!?[:0]const u8 { - var buf_stream = std.io.fixedBufferStream(buf); - const writer = buf_stream.writer(); - - // Modifiers - if (trigger.mods.shift) try writer.writeAll("SHIFT+"); - if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); - if (trigger.mods.alt) try writer.writeAll("ALT+"); - if (trigger.mods.super) try writer.writeAll("LOGO+"); - - // Write our key - // NOTE: While the spec specifies that only libxkbcommon keysyms are - // expected, using GTK's keysyms should still work as they are identical - // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). - // I haven't been able to any evidence to back up that assumption but - // this works for now - if (!try writeTriggerKey(writer, trigger)) return null; - - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; -} - -fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool { - switch (trigger.key) { - .physical => |k| { - const keyval = keyvalFromKey(k) orelse return false; - try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); - }, - - .unicode => |cp| { - if (gdk.keyvalName(cp)) |name| { - try writer.writeAll(std.mem.span(name)); - } else { - try writer.print("{u}", .{cp}); - } - }, - } - - return true; -} - -pub fn translateMods(state: gdk.ModifierType) input.Mods { - return .{ - .shift = state.shift_mask, - .ctrl = state.control_mask, - .alt = state.alt_mask, - .super = state.super_mask, - // Lock is dependent on the X settings but we just assume caps lock. - .caps_lock = state.lock_mask, - }; -} - -// Get the unshifted unicode value of the keyval. This is used -// by the Kitty keyboard protocol. -pub fn keyvalUnicodeUnshifted( - widget: *gtk.Widget, - event: *gdk.KeyEvent, - keycode: u32, -) u21 { - const display = widget.getDisplay(); - - // We need to get the currently active keyboard layout so we know - // what group to look at. - const layout = event.getLayout(); - - // Get all the possible keyboard mappings for this keycode. A keycode is the - // physical key pressed. - var keys: [*]gdk.KeymapKey = undefined; - var keyvals: [*]c_uint = undefined; - var n_entries: c_int = 0; - if (display.mapKeycode(keycode, &keys, &keyvals, &n_entries) == 0) return 0; - - defer glib.free(keys); - defer glib.free(keyvals); - - // debugging: - // std.log.debug("layout={}", .{layout}); - // for (0..@intCast(n_entries)) |i| { - // std.log.debug("keymap key={} codepoint={x}", .{ - // keys[i], - // gdk.keyvalToUnicode(keyvals[i]), - // }); - // } - - for (0..@intCast(n_entries)) |i| { - if (keys[i].f_group == layout and - keys[i].f_level == 0) - { - return std.math.cast( - u21, - gdk.keyvalToUnicode(keyvals[i]), - ) orelse 0; - } - } - - return 0; -} - -/// Returns the mods to use a key event from a GTK event. -/// This requires a lot of context because the GdkEvent -/// doesn't contain enough on its own. -pub fn eventMods( - event: *gdk.Event, - physical_key: input.Key, - gtk_mods: gdk.ModifierType, - action: input.Action, - app_winproto: *winproto.App, -) input.Mods { - const device = event.getDevice(); - - var mods = app_winproto.eventMods(device, gtk_mods); - mods.num_lock = if (device) |d| d.getNumLockState() != 0 else false; - - // We use the physical key to determine sided modifiers. As - // far as I can tell there's no other way to reliably determine - // this. - // - // We also set the main modifier to true if either side is true, - // since on both X11/Wayland, GTK doesn't set the main modifier - // if only the modifier key is pressed, but our core logic - // relies on it. - switch (physical_key) { - .shift_left => { - mods.shift = action != .release; - mods.sides.shift = .left; - }, - - .shift_right => { - mods.shift = action != .release; - mods.sides.shift = .right; - }, - - .control_left => { - mods.ctrl = action != .release; - mods.sides.ctrl = .left; - }, - - .control_right => { - mods.ctrl = action != .release; - mods.sides.ctrl = .right; - }, - - .alt_left => { - mods.alt = action != .release; - mods.sides.alt = .left; - }, - - .alt_right => { - mods.alt = action != .release; - mods.sides.alt = .right; - }, - - .meta_left => { - mods.super = action != .release; - mods.sides.super = .left; - }, - - .meta_right => { - mods.super = action != .release; - mods.sides.super = .right; - }, - - else => {}, - } - - return mods; -} - -/// Returns an input key from a keyval or null if we don't have a mapping. -pub fn keyFromKeyval(keyval: c_uint) ?input.Key { - for (keymap) |entry| { - if (entry[0] == keyval) return entry[1]; - } - - return null; -} - -/// Returns a keyval from an input key or null if we don't have a mapping. -pub fn keyvalFromKey(key: input.Key) ?c_uint { - switch (key) { - inline else => |key_comptime| { - return comptime value: { - @setEvalBranchQuota(50_000); - for (keymap) |entry| { - if (entry[1] == key_comptime) break :value entry[0]; - } - - break :value null; - }; - }, - } -} - -test "accelFromTrigger" { - const testing = std.testing; - var buf: [256]u8 = undefined; - - try testing.expectEqualStrings("q", (try accelFromTrigger(&buf, .{ - .mods = .{ .super = true }, - .key = .{ .unicode = 'q' }, - })).?); - - try testing.expectEqualStrings("backslash", (try accelFromTrigger(&buf, .{ - .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, - .key = .{ .unicode = 92 }, - })).?); -} - -test "xdgShortcutFromTrigger" { - const testing = std.testing; - var buf: [256]u8 = undefined; - - try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ - .mods = .{ .super = true }, - .key = .{ .unicode = 'q' }, - })).?); - - try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ - .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, - .key = .{ .unicode = 92 }, - })).?); -} - -/// A raw entry in the keymap. Our keymap contains mappings between -/// GDK keys and our own key enum. -const RawEntry = struct { c_uint, input.Key }; - -const keymap: []const RawEntry = &.{ - .{ gdk.KEY_a, .key_a }, - .{ gdk.KEY_b, .key_b }, - .{ gdk.KEY_c, .key_c }, - .{ gdk.KEY_d, .key_d }, - .{ gdk.KEY_e, .key_e }, - .{ gdk.KEY_f, .key_f }, - .{ gdk.KEY_g, .key_g }, - .{ gdk.KEY_h, .key_h }, - .{ gdk.KEY_i, .key_i }, - .{ gdk.KEY_j, .key_j }, - .{ gdk.KEY_k, .key_k }, - .{ gdk.KEY_l, .key_l }, - .{ gdk.KEY_m, .key_m }, - .{ gdk.KEY_n, .key_n }, - .{ gdk.KEY_o, .key_o }, - .{ gdk.KEY_p, .key_p }, - .{ gdk.KEY_q, .key_q }, - .{ gdk.KEY_r, .key_r }, - .{ gdk.KEY_s, .key_s }, - .{ gdk.KEY_t, .key_t }, - .{ gdk.KEY_u, .key_u }, - .{ gdk.KEY_v, .key_v }, - .{ gdk.KEY_w, .key_w }, - .{ gdk.KEY_x, .key_x }, - .{ gdk.KEY_y, .key_y }, - .{ gdk.KEY_z, .key_z }, - - .{ gdk.KEY_0, .digit_0 }, - .{ gdk.KEY_1, .digit_1 }, - .{ gdk.KEY_2, .digit_2 }, - .{ gdk.KEY_3, .digit_3 }, - .{ gdk.KEY_4, .digit_4 }, - .{ gdk.KEY_5, .digit_5 }, - .{ gdk.KEY_6, .digit_6 }, - .{ gdk.KEY_7, .digit_7 }, - .{ gdk.KEY_8, .digit_8 }, - .{ gdk.KEY_9, .digit_9 }, - - .{ gdk.KEY_semicolon, .semicolon }, - .{ gdk.KEY_space, .space }, - .{ gdk.KEY_apostrophe, .quote }, - .{ gdk.KEY_comma, .comma }, - .{ gdk.KEY_grave, .backquote }, - .{ gdk.KEY_period, .period }, - .{ gdk.KEY_slash, .slash }, - .{ gdk.KEY_minus, .minus }, - .{ gdk.KEY_equal, .equal }, - .{ gdk.KEY_bracketleft, .bracket_left }, - .{ gdk.KEY_bracketright, .bracket_right }, - .{ gdk.KEY_backslash, .backslash }, - - .{ gdk.KEY_Up, .arrow_up }, - .{ gdk.KEY_Down, .arrow_down }, - .{ gdk.KEY_Right, .arrow_right }, - .{ gdk.KEY_Left, .arrow_left }, - .{ gdk.KEY_Home, .home }, - .{ gdk.KEY_End, .end }, - .{ gdk.KEY_Insert, .insert }, - .{ gdk.KEY_Delete, .delete }, - .{ gdk.KEY_Caps_Lock, .caps_lock }, - .{ gdk.KEY_Scroll_Lock, .scroll_lock }, - .{ gdk.KEY_Num_Lock, .num_lock }, - .{ gdk.KEY_Page_Up, .page_up }, - .{ gdk.KEY_Page_Down, .page_down }, - .{ gdk.KEY_Escape, .escape }, - .{ gdk.KEY_Return, .enter }, - .{ gdk.KEY_Tab, .tab }, - .{ gdk.KEY_BackSpace, .backspace }, - .{ gdk.KEY_Print, .print_screen }, - .{ gdk.KEY_Pause, .pause }, - - .{ gdk.KEY_F1, .f1 }, - .{ gdk.KEY_F2, .f2 }, - .{ gdk.KEY_F3, .f3 }, - .{ gdk.KEY_F4, .f4 }, - .{ gdk.KEY_F5, .f5 }, - .{ gdk.KEY_F6, .f6 }, - .{ gdk.KEY_F7, .f7 }, - .{ gdk.KEY_F8, .f8 }, - .{ gdk.KEY_F9, .f9 }, - .{ gdk.KEY_F10, .f10 }, - .{ gdk.KEY_F11, .f11 }, - .{ gdk.KEY_F12, .f12 }, - .{ gdk.KEY_F13, .f13 }, - .{ gdk.KEY_F14, .f14 }, - .{ gdk.KEY_F15, .f15 }, - .{ gdk.KEY_F16, .f16 }, - .{ gdk.KEY_F17, .f17 }, - .{ gdk.KEY_F18, .f18 }, - .{ gdk.KEY_F19, .f19 }, - .{ gdk.KEY_F20, .f20 }, - .{ gdk.KEY_F21, .f21 }, - .{ gdk.KEY_F22, .f22 }, - .{ gdk.KEY_F23, .f23 }, - .{ gdk.KEY_F24, .f24 }, - .{ gdk.KEY_F25, .f25 }, - - .{ gdk.KEY_KP_0, .numpad_0 }, - .{ gdk.KEY_KP_1, .numpad_1 }, - .{ gdk.KEY_KP_2, .numpad_2 }, - .{ gdk.KEY_KP_3, .numpad_3 }, - .{ gdk.KEY_KP_4, .numpad_4 }, - .{ gdk.KEY_KP_5, .numpad_5 }, - .{ gdk.KEY_KP_6, .numpad_6 }, - .{ gdk.KEY_KP_7, .numpad_7 }, - .{ gdk.KEY_KP_8, .numpad_8 }, - .{ gdk.KEY_KP_9, .numpad_9 }, - .{ gdk.KEY_KP_Decimal, .numpad_decimal }, - .{ gdk.KEY_KP_Divide, .numpad_divide }, - .{ gdk.KEY_KP_Multiply, .numpad_multiply }, - .{ gdk.KEY_KP_Subtract, .numpad_subtract }, - .{ gdk.KEY_KP_Add, .numpad_add }, - .{ gdk.KEY_KP_Enter, .numpad_enter }, - .{ gdk.KEY_KP_Equal, .numpad_equal }, - - .{ gdk.KEY_KP_Separator, .numpad_separator }, - .{ gdk.KEY_KP_Left, .numpad_left }, - .{ gdk.KEY_KP_Right, .numpad_right }, - .{ gdk.KEY_KP_Up, .numpad_up }, - .{ gdk.KEY_KP_Down, .numpad_down }, - .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, - .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, - .{ gdk.KEY_KP_Home, .numpad_home }, - .{ gdk.KEY_KP_End, .numpad_end }, - .{ gdk.KEY_KP_Insert, .numpad_insert }, - .{ gdk.KEY_KP_Delete, .numpad_delete }, - .{ gdk.KEY_KP_Begin, .numpad_begin }, - - .{ gdk.KEY_Copy, .copy }, - .{ gdk.KEY_Cut, .cut }, - .{ gdk.KEY_Paste, .paste }, - - .{ gdk.KEY_Shift_L, .shift_left }, - .{ gdk.KEY_Control_L, .control_left }, - .{ gdk.KEY_Alt_L, .alt_left }, - .{ gdk.KEY_Super_L, .meta_left }, - .{ gdk.KEY_Shift_R, .shift_right }, - .{ gdk.KEY_Control_R, .control_right }, - .{ gdk.KEY_Alt_R, .alt_right }, - .{ gdk.KEY_Super_R, .meta_right }, - - // TODO: media keys -}; diff --git a/src/apprt/gtk-ng/ui/1.2/config-errors-dialog.blp b/src/apprt/gtk-ng/ui/1.2/config-errors-dialog.blp deleted file mode 100644 index 845909eb3..000000000 --- a/src/apprt/gtk-ng/ui/1.2/config-errors-dialog.blp +++ /dev/null @@ -1,28 +0,0 @@ -using Gtk 4.0; -// This is unused but if we remove it we get a blueprint-compiler error. -using Adw 1; - -template $GhosttyConfigErrorsDialog: $GhosttyDialog { - heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); - - responses [ - ignore: _("Ignore"), - reload: _("Reload Configuration") suggested, - ] - - extra-child: ScrolledWindow { - min-content-width: 500; - min-content-height: 100; - - TextView { - editable: false; - cursor-visible: false; - top-margin: 8; - bottom-margin: 8; - left-margin: 8; - right-margin: 8; - buffer: bind (template.config as <$GhosttyConfig>).diagnostics-buffer; - } - }; -} diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp deleted file mode 100644 index 39c88ff33..000000000 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ /dev/null @@ -1,292 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -template $GhosttySurface: Adw.Bin { - styles [ - "surface", - ] - - notify::bell-ringing => $notify_bell_ringing(); - notify::config => $notify_config(); - notify::error => $notify_error(); - notify::mouse-hover-url => $notify_mouse_hover_url(); - notify::mouse-hidden => $notify_mouse_hidden(); - notify::mouse-shape => $notify_mouse_shape(); - - Stack { - StackPage { - name: "terminal"; - - child: Overlay { - focusable: false; - focus-on-click: false; - - child: Box { - hexpand: true; - vexpand: true; - - GLArea gl_area { - realize => $gl_realize(); - unrealize => $gl_unrealize(); - render => $gl_render(); - resize => $gl_resize(); - hexpand: true; - vexpand: true; - focusable: true; - focus-on-click: true; - has-stencil-buffer: false; - has-depth-buffer: false; - allowed-apis: gl; - } - - PopoverMenu context_menu { - closed => $context_menu_closed(); - menu-model: context_menu_model; - flags: nested; - halign: start; - has-arrow: false; - } - }; - - [overlay] - ProgressBar progress_bar_overlay { - styles [ - "osd", - ] - - visible: false; - halign: fill; - valign: start; - } - - [overlay] - // The "border" bell feature is implemented here as an overlay rather than - // just adding a border to the GLArea or other widget for two reasons. - // First, adding a border to an existing widget causes a resize of the - // widget which undesirable side effects. Second, we can make it reactive - // here in the blueprint with relatively little code. - Revealer { - reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; - transition-type: crossfade; - transition-duration: 500; - - Box bell_overlay { - styles [ - "bell-overlay", - ] - - halign: fill; - valign: fill; - } - } - - [overlay] - $GhosttySurfaceChildExited child_exited_overlay { - visible: bind template.child-exited; - close-request => $child_exited_close(); - } - - [overlay] - $GhosttyResizeOverlay resize_overlay {} - - [overlay] - Label url_left { - styles [ - "background", - "url-overlay", - ] - - visible: false; - halign: start; - valign: end; - label: bind template.mouse-hover-url; - - EventControllerMotion url_ec_motion { - enter => $url_mouse_enter(); - leave => $url_mouse_leave(); - } - } - - [overlay] - Label url_right { - styles [ - "background", - "url-overlay", - ] - - visible: false; - halign: end; - valign: end; - label: bind template.mouse-hover-url; - } - - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - - DropTarget drop_target { - drop => $drop(); - actions: copy; - } - }; - } - - StackPage { - name: "error"; - - child: Adw.StatusPage { - icon-name: "computer-fail-symbolic"; - title: _("Oh, no."); - description: _("Unable to acquire an OpenGL context for rendering."); - - child: LinkButton { - label: "https://ghostty.org/docs/help/gtk-opengl-context"; - uri: "https://ghostty.org/docs/help/gtk-opengl-context"; - }; - }; - } - - // The order matters here: we can only set this after the stack - // pages above have been created. - visible-child-name: bind $stack_child_name(template.error) as ; - } -} - -IMMulticontext im_context { - input-purpose: terminal; - preedit-start => $im_preedit_start(); - preedit-changed => $im_preedit_changed(); - preedit-end => $im_preedit_end(); - commit => $im_commit(); -} - -menu context_menu_model { - section { - item { - label: _("Copy"); - action: "win.copy"; - } - - item { - label: _("Paste"); - action: "win.paste"; - } - } - - section { - item { - label: _("Clear"); - action: "win.clear"; - } - - item { - label: _("Reset"); - action: "win.reset"; - } - } - - section { - submenu { - label: _("Split"); - - item { - label: _("Change Title…"); - action: "surface.prompt-title"; - } - - item { - label: _("Split Up"); - action: "split-tree.new-split"; - target: "up"; - } - - item { - label: _("Split Down"); - action: "split-tree.new-split"; - target: "down"; - } - - item { - label: _("Split Left"); - action: "split-tree.new-split"; - target: "left"; - } - - item { - label: _("Split Right"); - action: "split-tree.new-split"; - target: "right"; - } - } - - submenu { - label: _("Tab"); - - item { - label: _("New Tab"); - action: "win.new-tab"; - } - - item { - label: _("Close Tab"); - action: "tab.close"; - target: "this"; - } - } - - submenu { - label: _("Window"); - - item { - label: _("New Window"); - action: "win.new-window"; - } - - item { - label: _("Close Window"); - action: "win.close"; - } - } - } - - section { - submenu { - label: _("Config"); - - item { - label: _("Open Configuration"); - action: "app.open-config"; - } - - item { - label: _("Reload Configuration"); - action: "app.reload-config"; - } - } - } -} diff --git a/src/apprt/gtk-ng/ui/1.5/command-palette.blp b/src/apprt/gtk-ng/ui/1.5/command-palette.blp deleted file mode 100644 index 473fb1f06..000000000 --- a/src/apprt/gtk-ng/ui/1.5/command-palette.blp +++ /dev/null @@ -1,110 +0,0 @@ -using Gtk 4.0; -using Gio 2.0; -using Adw 1; - -Adw.Dialog dialog { - content-width: 700; - closed => $closed(); - - Adw.ToolbarView { - top-bar-style: flat; - - [top] - Adw.HeaderBar { - [title] - Gtk.SearchEntry search { - hexpand: true; - placeholder-text: _("Execute a command…"); - stop-search => $search_stopped(); - activate => $search_activated(); - - styles [ - "command-palette-search", - ] - } - } - - Gtk.ScrolledWindow { - min-content-height: 300; - - Gtk.ListView view { - show-separators: true; - single-click-activate: true; - activate => $row_activated(); - - model: Gtk.SingleSelection model { - model: Gtk.FilterListModel { - incremental: true; - - filter: Gtk.AnyFilter { - Gtk.StringFilter { - expression: expr item as <$GhosttyCommand>.title; - search: bind search.text; - } - - Gtk.StringFilter { - expression: expr item as <$GhosttyCommand>.action-key; - search: bind search.text; - } - }; - - model: Gio.ListStore source { - item-type: typeof<$GhosttyCommand>; - }; - }; - }; - - styles [ - "rich-list", - ] - - factory: Gtk.BuilderListItemFactory { - template Gtk.ListItem { - child: Gtk.Box { - orientation: horizontal; - spacing: 10; - tooltip-text: bind template.item as <$GhosttyCommand>.description; - - Gtk.Box { - orientation: vertical; - hexpand: true; - - Gtk.Label { - ellipsize: end; - halign: start; - wrap: false; - single-line-mode: true; - - styles [ - "title", - ] - - label: bind template.item as <$GhosttyCommand>.title; - } - - Gtk.Label { - ellipsize: end; - halign: start; - wrap: false; - single-line-mode: true; - - styles [ - "subtitle", - "monospace", - ] - - label: bind template.item as <$GhosttyCommand>.action-key; - } - } - - Gtk.ShortcutLabel { - accelerator: bind template.item as <$GhosttyCommand>.action; - valign: center; - } - }; - } - }; - } - } - } -} diff --git a/src/apprt/gtk-ng/winproto.zig b/src/apprt/gtk-ng/winproto.zig deleted file mode 100644 index 3c1da2b21..000000000 --- a/src/apprt/gtk-ng/winproto.zig +++ /dev/null @@ -1,155 +0,0 @@ -const std = @import("std"); -const build_options = @import("build_options"); -const Allocator = std.mem.Allocator; - -const gdk = @import("gdk"); - -const Config = @import("../../config.zig").Config; -const input = @import("../../input.zig"); -const key = @import("key.zig"); -const ApprtWindow = @import("class/window.zig").Window; - -pub const noop = @import("winproto/noop.zig"); -pub const x11 = @import("winproto/x11.zig"); -pub const wayland = @import("winproto/wayland.zig"); - -pub const Protocol = enum { - none, - wayland, - x11, -}; - -/// App-state for the underlying windowing protocol. There should be one -/// instance of this struct per application. -pub const App = union(Protocol) { - none: noop.App, - wayland: if (build_options.wayland) wayland.App else noop.App, - x11: if (build_options.x11) x11.App else noop.App, - - pub fn init( - alloc: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !App { - inline for (@typeInfo(App).@"union".fields) |field| { - if (try field.type.init( - alloc, - gdk_display, - app_id, - config, - )) |v| { - return @unionInit(App, field.name, v); - } - } - - return .{ .none = .{} }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - switch (self.*) { - inline else => |*v| v.deinit(alloc), - } - } - - pub fn eventMods( - self: *App, - device: ?*gdk.Device, - gtk_mods: gdk.ModifierType, - ) input.Mods { - return switch (self.*) { - inline else => |*v| v.eventMods(device, gtk_mods), - } orelse key.translateMods(gtk_mods); - } - - pub fn supportsQuickTerminal(self: App) bool { - return switch (self) { - inline else => |v| v.supportsQuickTerminal(), - }; - } - - /// Set up necessary support for the quick terminal that must occur - /// *before* the window-level winproto object is created. - /// - /// Only has an effect on the Wayland backend, where the gtk4-layer-shell - /// library is initialized. - pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { - switch (self.*) { - inline else => |*v| try v.initQuickTerminal(apprt_window), - } - } -}; - -/// Per-Window state for the underlying windowing protocol. -/// -/// In Wayland, the terminology used is "Surface" and for it, this is -/// really "Surface"-specific state. But Ghostty uses the term "Surface" -/// heavily to mean something completely different, so we use "Window" here -/// to better match what it generally maps to in the Ghostty codebase. -pub const Window = union(Protocol) { - none: noop.Window, - wayland: if (build_options.wayland) wayland.Window else noop.Window, - x11: if (build_options.x11) x11.Window else noop.Window, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - return switch (app.*) { - inline else => |*v, tag| { - inline for (@typeInfo(Window).@"union".fields) |field| { - if (comptime std.mem.eql( - u8, - field.name, - @tagName(tag), - )) return @unionInit( - Window, - field.name, - try field.type.init( - alloc, - v, - apprt_window, - ), - ); - } - }, - }; - } - - pub fn deinit(self: *Window, alloc: Allocator) void { - switch (self.*) { - inline else => |*v| v.deinit(alloc), - } - } - - pub fn resizeEvent(self: *Window) !void { - switch (self.*) { - inline else => |*v| try v.resizeEvent(), - } - } - - pub fn syncAppearance(self: *Window) !void { - switch (self.*) { - inline else => |*v| try v.syncAppearance(), - } - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self) { - inline else => |v| v.clientSideDecorationEnabled(), - }; - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - switch (self.*) { - inline else => |*v| try v.addSubprocessEnv(env), - } - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - switch (self.*) { - inline else => |*v| try v.setUrgent(urgent), - } - } -}; diff --git a/src/apprt/gtk-ng/winproto/noop.zig b/src/apprt/gtk-ng/winproto/noop.zig deleted file mode 100644 index ed69736f8..000000000 --- a/src/apprt/gtk-ng/winproto/noop.zig +++ /dev/null @@ -1,75 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gdk = @import("gdk"); - -const Config = @import("../../../config.zig").Config; -const input = @import("../../../input.zig"); -const ApprtWindow = @import("../class/window.zig").Window; - -const log = std.log.scoped(.winproto_noop); - -pub const App = struct { - pub fn init( - _: Allocator, - _: *gdk.Display, - _: [:0]const u8, - _: *const Config, - ) !?App { - return null; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn eventMods( - _: *App, - _: ?*gdk.Device, - _: gdk.ModifierType, - ) ?input.Mods { - return null; - } - - pub fn supportsQuickTerminal(_: App) bool { - return false; - } - pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} -}; - -pub const Window = struct { - pub fn init( - _: Allocator, - _: *App, - _: *ApprtWindow, - ) !Window { - return .{}; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn updateConfigEvent( - _: *Window, - _: *const ApprtWindow.DerivedConfig, - ) !void {} - - pub fn resizeEvent(_: *Window) !void {} - - pub fn syncAppearance(_: *Window) !void {} - - /// This returns true if CSD is enabled for this window. This - /// should be the actual present state of the window, not the - /// desired state. - pub fn clientSideDecorationEnabled(self: Window) bool { - _ = self; - return true; - } - - pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} - - pub fn setUrgent(_: *Window, _: bool) !void {} -}; diff --git a/src/apprt/gtk-ng/winproto/wayland.zig b/src/apprt/gtk-ng/winproto/wayland.zig deleted file mode 100644 index 0ab7c24f0..000000000 --- a/src/apprt/gtk-ng/winproto/wayland.zig +++ /dev/null @@ -1,518 +0,0 @@ -//! Wayland protocol implementation for the Ghostty GTK apprt. -const std = @import("std"); -const Allocator = std.mem.Allocator; -const build_options = @import("build_options"); - -const gdk = @import("gdk"); -const gdk_wayland = @import("gdk_wayland"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); -const layer_shell = @import("gtk4-layer-shell"); -const wayland = @import("wayland"); - -const Config = @import("../../../config.zig").Config; -const input = @import("../../../input.zig"); -const ApprtWindow = @import("../class/window.zig").Window; - -const wl = wayland.client.wl; -const org = wayland.client.org; -const xdg = wayland.client.xdg; - -const log = std.log.scoped(.winproto_wayland); - -/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const App = struct { - display: *wl.Display, - context: *Context, - - const Context = struct { - kde_blur_manager: ?*org.KdeKwinBlurManager = null, - - // FIXME: replace with `zxdg_decoration_v1` once GTK merges - // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 - kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, - - kde_slide_manager: ?*org.KdeKwinSlideManager = null, - - default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, - - xdg_activation: ?*xdg.ActivationV1 = null, - - /// Whether the xdg_wm_dialog_v1 protocol is present. - /// - /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user - /// creates a quick terminal, and we need to ensure this fails - /// gracefully if this situation occurs. - /// - /// FIXME: This is a temporary workaround - we should remove this when - /// all of our supported distros drop support for affected old - /// gtk4-layer-shell versions. - /// - /// See https://github.com/wmww/gtk4-layer-shell/issues/50 - xdg_wm_dialog_present: bool = false, - }; - - pub fn init( - alloc: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !?App { - _ = config; - _ = app_id; - - const gdk_wayland_display = gobject.ext.cast( - gdk_wayland.WaylandDisplay, - gdk_display, - ) orelse return null; - - const display: *wl.Display = @ptrCast(@alignCast( - gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, - )); - - // Create our context for our callbacks so we have a stable pointer. - // Note: at the time of writing this comment, we don't really need - // a stable pointer, but it's too scary that we'd need one in the future - // and not have it and corrupt memory or something so let's just do it. - const context = try alloc.create(Context); - errdefer alloc.destroy(context); - context.* = .{}; - - // Get our display registry so we can get all the available interfaces - // and bind to what we need. - const registry = try display.getRegistry(); - registry.setListener(*Context, registryListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - - // Do another round-trip to get the default decoration mode - if (context.kde_decoration_manager) |deco_manager| { - deco_manager.setListener(*Context, decoManagerListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - } - - return .{ - .display = display, - .context = context, - }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - alloc.destroy(self.context); - } - - pub fn eventMods( - _: *App, - _: ?*gdk.Device, - _: gdk.ModifierType, - ) ?input.Mods { - return null; - } - - pub fn supportsQuickTerminal(self: App) bool { - if (!layer_shell.isSupported()) { - log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); - return false; - } - - if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ - .major = 1, - .minor = 0, - .patch = 4, - }) == .lt) { - log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); - return false; - } - - return true; - } - - pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { - const window = apprt_window.as(gtk.Window); - - layer_shell.initForWindow(window); - layer_shell.setLayer(window, .top); - layer_shell.setNamespace(window, "ghostty-quick-terminal"); - } - - fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { - // Globals should be optional pointers - const T = switch (@typeInfo(field.type)) { - .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, - else => return null, - }, - else => return null, - }; - - // Only process Wayland interfaces - if (!@hasDecl(T, "interface")) return null; - return T; - } - - fn registryListener( - registry: *wl.Registry, - event: wl.Registry.Event, - context: *Context, - ) void { - const ctx_fields = @typeInfo(Context).@"struct".fields; - - switch (event) { - .global => |v| { - log.debug("found global {s}", .{v.interface}); - - // We don't actually do anything with this other than checking - // for its existence, so we process this separately. - if (std.mem.orderZ( - u8, - v.interface, - "xdg_wm_dialog_v1", - ) == .eq) { - context.xdg_wm_dialog_present = true; - return; - } - - inline for (ctx_fields) |field| { - const T = getInterfaceType(field) orelse continue; - - if (std.mem.orderZ( - u8, - v.interface, - T.interface.name, - ) == .eq) { - log.debug("matched {}", .{T}); - - @field(context, field.name) = registry.bind( - v.name, - T, - T.generated_version, - ) catch |err| { - log.warn( - "error binding interface {s} error={}", - .{ v.interface, err }, - ); - return; - }; - } - } - }, - - // This should be a rare occurrence, but in case a global - // is suddenly no longer available, we destroy and unset it - // as the protocol mandates. - .global_remove => |v| remove: { - inline for (ctx_fields) |field| { - if (getInterfaceType(field) == null) continue; - const global = @field(context, field.name) orelse break :remove; - if (global.getId() == v.name) { - global.destroy(); - @field(context, field.name) = null; - } - } - }, - } - } - - fn decoManagerListener( - _: *org.KdeKwinServerDecorationManager, - event: org.KdeKwinServerDecorationManager.Event, - context: *Context, - ) void { - switch (event) { - .default_mode => |mode| { - context.default_deco_mode = @enumFromInt(mode.mode); - }, - } - } -}; - -/// Per-window (wl_surface) state for the Wayland protocol. -pub const Window = struct { - apprt_window: *ApprtWindow, - - /// The Wayland surface for this window. - surface: *wl.Surface, - - /// The context from the app where we can load our Wayland interfaces. - app_context: *App.Context, - - /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, - - /// Object that controls the decoration mode (client/server/auto) - /// of the window. - decoration: ?*org.KdeKwinServerDecoration = null, - - /// Object that controls the slide-in/slide-out animations of the - /// quick terminal. Always null for windows other than the quick terminal. - slide: ?*org.KdeKwinSlide = null, - - /// Object that, when present, denotes that the window is currently - /// requesting attention from the user. - activation_token: ?*xdg.ActivationTokenV1 = null, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - _ = alloc; - - const gtk_native = apprt_window.as(gtk.Native); - const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface; - - // This should never fail, because if we're being called at this point - // then we've already asserted that our app state is Wayland. - const gdk_wl_surface = gobject.ext.cast( - gdk_wayland.WaylandSurface, - gdk_surface, - ) orelse return error.NoWaylandSurface; - - const wl_surface: *wl.Surface = @ptrCast(@alignCast( - gdk_wl_surface.getWlSurface() orelse return error.NoWaylandSurface, - )); - - // Get our decoration object so we can control the - // CSD vs SSD status of this surface. - const deco: ?*org.KdeKwinServerDecoration = deco: { - const mgr = app.context.kde_decoration_manager orelse - break :deco null; - - const deco: *org.KdeKwinServerDecoration = mgr.create( - wl_surface, - ) catch |err| { - log.warn("could not create decoration object={}", .{err}); - break :deco null; - }; - - break :deco deco; - }; - - if (apprt_window.isQuickTerminal()) { - _ = gdk.Surface.signals.enter_monitor.connect( - gdk_surface, - *ApprtWindow, - enteredMonitor, - apprt_window, - .{}, - ); - } - - return .{ - .apprt_window = apprt_window, - .surface = wl_surface, - .app_context = app.context, - .decoration = deco, - }; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = alloc; - if (self.blur_token) |blur| blur.release(); - if (self.decoration) |deco| deco.release(); - if (self.slide) |slide| slide.release(); - } - - pub fn resizeEvent(_: *Window) !void {} - - pub fn syncAppearance(self: *Window) !void { - self.syncBlur() catch |err| { - log.err("failed to sync blur={}", .{err}); - }; - self.syncDecoration() catch |err| { - log.err("failed to sync blur={}", .{err}); - }; - - if (self.apprt_window.isQuickTerminal()) { - self.syncQuickTerminal() catch |err| { - log.warn("failed to sync quick terminal appearance={}", .{err}); - }; - } - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self.getDecorationMode()) { - .Client => true, - // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs. - // However, if we do not support SSDs (e.g. GNOME) then we should enable - // CSDs even if the user prefers SSDs. - .Server => if (self.app_context.kde_decoration_manager) |_| false else true, - .None => false, - else => unreachable, - }; - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - _ = self; - _ = env; - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - const activation = self.app_context.xdg_activation orelse return; - - // If there already is a token, destroy and unset it - if (self.activation_token) |token| token.destroy(); - - self.activation_token = if (urgent) token: { - const token = try activation.getActivationToken(); - token.setSurface(self.surface); - token.setListener(*Window, onActivationTokenEvent, self); - token.commit(); - break :token token; - } else null; - } - - /// Update the blur state of the window. - fn syncBlur(self: *Window) !void { - const manager = self.app_context.kde_blur_manager orelse return; - const config = if (self.apprt_window.getConfig()) |v| - v.get() - else - return; - const blur = config.@"background-blur"; - - if (self.blur_token) |tok| { - // Only release token when transitioning from blurred -> not blurred - if (!blur.enabled()) { - manager.unset(self.surface); - tok.release(); - self.blur_token = null; - } - } else { - // Only acquire token when transitioning from not blurred -> blurred - if (blur.enabled()) { - const tok = try manager.create(self.surface); - tok.commit(); - self.blur_token = tok; - } - } - } - - fn syncDecoration(self: *Window) !void { - const deco = self.decoration orelse return; - - // The protocol requests uint instead of enum so we have - // to convert it. - deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode()))); - } - - fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { - return switch (self.apprt_window.getWindowDecoration()) { - .auto => self.app_context.default_deco_mode orelse .Client, - .client => .Client, - .server => .Server, - .none => .None, - }; - } - - fn syncQuickTerminal(self: *Window) !void { - const window = self.apprt_window.as(gtk.Window); - const config = if (self.apprt_window.getConfig()) |v| - v.get() - else - return; - - layer_shell.setKeyboardMode( - window, - switch (config.@"quick-terminal-keyboard-interactivity") { - .none => .none, - .@"on-demand" => on_demand: { - if (layer_shell.getProtocolVersion() < 4) { - log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); - break :on_demand .exclusive; - } - break :on_demand .on_demand; - }, - .exclusive => .exclusive, - }, - ); - - const anchored_edge: ?layer_shell.ShellEdge = switch (config.@"quick-terminal-position") { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - .center => null, - }; - - for (std.meta.tags(layer_shell.ShellEdge)) |edge| { - if (anchored_edge) |anchored| { - if (edge == anchored) { - layer_shell.setMargin(window, edge, 0); - layer_shell.setAnchor(window, edge, true); - continue; - } - } - - // Arbitrary margin - could be made customizable? - layer_shell.setMargin(window, edge, 20); - layer_shell.setAnchor(window, edge, false); - } - - if (self.slide) |slide| slide.release(); - - self.slide = if (anchored_edge) |anchored| slide: { - const mgr = self.app_context.kde_slide_manager orelse break :slide null; - - const slide = mgr.create(self.surface) catch |err| { - log.warn("could not create slide object={}", .{err}); - break :slide null; - }; - - const slide_location: org.KdeKwinSlide.Location = switch (anchored) { - .top => .top, - .bottom => .bottom, - .left => .left, - .right => .right, - }; - - slide.setLocation(@intCast(@intFromEnum(slide_location))); - slide.commit(); - break :slide slide; - } else null; - } - - /// Update the size of the quick terminal based on monitor dimensions. - fn enteredMonitor( - _: *gdk.Surface, - monitor: *gdk.Monitor, - apprt_window: *ApprtWindow, - ) callconv(.c) void { - const window = apprt_window.as(gtk.Window); - const config = if (apprt_window.getConfig()) |v| v.get() else return; - - var monitor_size: gdk.Rectangle = undefined; - monitor.getGeometry(&monitor_size); - - const dims = config.@"quick-terminal-size".calculate( - config.@"quick-terminal-position", - .{ - .width = @intCast(monitor_size.f_width), - .height = @intCast(monitor_size.f_height), - }, - ); - - window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); - } - - fn onActivationTokenEvent( - token: *xdg.ActivationTokenV1, - event: xdg.ActivationTokenV1.Event, - self: *Window, - ) void { - const activation = self.app_context.xdg_activation orelse return; - const current_token = self.activation_token orelse return; - - if (token.getId() != current_token.getId()) { - log.warn("received event for unknown activation token; ignoring", .{}); - return; - } - - switch (event) { - .done => |done| { - activation.activate(done.token, self.surface); - token.destroy(); - self.activation_token = null; - }, - } - } -}; diff --git a/src/apprt/gtk-ng/winproto/x11.zig b/src/apprt/gtk-ng/winproto/x11.zig deleted file mode 100644 index 8956a29ed..000000000 --- a/src/apprt/gtk-ng/winproto/x11.zig +++ /dev/null @@ -1,505 +0,0 @@ -//! X11 window protocol implementation for the Ghostty GTK apprt. -const std = @import("std"); -const builtin = @import("builtin"); -const build_options = @import("build_options"); -const Allocator = std.mem.Allocator; - -const adw = @import("adw"); -const gdk = @import("gdk"); -const gdk_x11 = @import("gdk_x11"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); -const xlib = @import("xlib"); - -pub const c = @cImport({ - @cInclude("X11/Xlib.h"); - @cInclude("X11/Xatom.h"); - @cInclude("X11/XKBlib.h"); -}); - -const input = @import("../../../input.zig"); -const Config = @import("../../../config.zig").Config; -const ApprtWindow = @import("../class/window.zig").Window; - -const log = std.log.scoped(.gtk_x11); - -pub const App = struct { - display: *xlib.Display, - base_event_code: c_int, - atoms: Atoms, - - pub fn init( - _: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !?App { - // If the display isn't X11, then we don't need to do anything. - const gdk_x11_display = gobject.ext.cast( - gdk_x11.X11Display, - gdk_display, - ) orelse return null; - - const xlib_display = gdk_x11_display.getXdisplay(); - - const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty"; - - // Set the X11 window class property (WM_CLASS) if are are on an X11 - // display. - // - // Note that we also set the program name here using g_set_prgname. - // This is how the instance name field for WM_CLASS is derived when - // calling gdk_x11_display_set_program_class; there does not seem to be - // a way to set it directly. It does not look like this is being set by - // our other app initialization routines currently, but since we're - // currently deriving its value from x11-instance-name effectively, I - // feel like gating it behind an X11 check is better intent. - // - // This makes the property show up like so when using xprop: - // - // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" - // - // Append "-debug" on both when using the debug build. - glib.setPrgname(x11_program_name); - gdk_x11.X11Display.setProgramClass(gdk_display, app_id); - - // XKB - log.debug("Xkb.init: initializing Xkb", .{}); - log.debug("Xkb.init: running XkbQueryExtension", .{}); - var opcode: c_int = 0; - var base_event_code: c_int = 0; - var base_error_code: c_int = 0; - var major = c.XkbMajorVersion; - var minor = c.XkbMinorVersion; - if (c.XkbQueryExtension( - @ptrCast(@alignCast(xlib_display)), - &opcode, - &base_event_code, - &base_error_code, - &major, - &minor, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); - return error.XkbInitializationError; - } - - log.debug("Xkb.init: running XkbSelectEventDetails", .{}); - if (c.XkbSelectEventDetails( - @ptrCast(@alignCast(xlib_display)), - c.XkbUseCoreKbd, - c.XkbStateNotify, - c.XkbModifierStateMask, - c.XkbModifierStateMask, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); - return error.XkbInitializationError; - } - - return .{ - .display = xlib_display, - .base_event_code = base_event_code, - .atoms = .init(gdk_x11_display), - }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - /// Checks for an immediate pending XKB state update event, and returns the - /// keyboard state based on if it finds any. This is necessary as the - /// standard GTK X11 API (and X11 in general) does not include the current - /// key pressed in any modifier state snapshot for that event (e.g. if the - /// pressed key is a modifier, that is not necessarily reflected in the - /// modifiers). - /// - /// Returns null if there is no event. In this case, the caller should fall - /// back to the standard GDK modifier state (this likely means the key - /// event did not result in a modifier change). - pub fn eventMods( - self: App, - device: ?*gdk.Device, - gtk_mods: gdk.ModifierType, - ) ?input.Mods { - _ = device; - _ = gtk_mods; - - // Shoutout to Mozilla for figuring out a clean way to do this, this is - // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. - if (c.XEventsQueued( - @ptrCast(@alignCast(self.display)), - c.QueuedAfterReading, - ) == 0) return null; - - var nextEvent: c.XEvent = undefined; - _ = c.XPeekEvent(@ptrCast(@alignCast(self.display)), &nextEvent); - if (nextEvent.type != self.base_event_code) return null; - - const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); - if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; - - const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); - // Check the state according to XKB masks. - const lookup_mods = xkb_state_notify_event.lookup_mods; - var mods: input.Mods = .{}; - - log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); - if (lookup_mods & c.ShiftMask != 0) mods.shift = true; - if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; - if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; - if (lookup_mods & c.Mod4Mask != 0) mods.super = true; - if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; - - return mods; - } - - pub fn supportsQuickTerminal(_: App) bool { - log.warn("quick terminal is not yet supported on X11", .{}); - return false; - } - - pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} -}; - -pub const Window = struct { - app: *App, - apprt_window: *ApprtWindow, - x11_surface: *gdk_x11.X11Surface, - - blur_region: Region = .{}, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - _ = alloc; - - const surface = apprt_window.as(gtk.Native).getSurface() orelse - return error.NotX11Surface; - - const x11_surface = gobject.ext.cast( - gdk_x11.X11Surface, - surface, - ) orelse return error.NotX11Surface; - - return .{ - .app = app, - .apprt_window = apprt_window, - .x11_surface = x11_surface, - }; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn resizeEvent(self: *Window) !void { - // The blur region must update with window resizes - try self.syncBlur(); - } - - pub fn syncAppearance(self: *Window) !void { - // The user could have toggled between CSDs and SSDs, - // therefore we need to recalculate the blur region offset. - self.blur_region = blur: { - // NOTE(pluiedev): CSDs are a f--king mistake. - // Please, GNOME, stop this nonsense of making a window ~30% bigger - // internally than how they really are just for your shadows and - // rounded corners and all that fluff. Please. I beg of you. - var x: f64 = 0; - var y: f64 = 0; - - self.apprt_window.as(gtk.Native).getSurfaceTransform(&x, &y); - - // Transform surface coordinates to device coordinates. - const scale: f64 = @floatFromInt(self.apprt_window.as(gtk.Widget).getScaleFactor()); - x *= scale; - y *= scale; - - break :blur .{ - .x = @intFromFloat(x), - .y = @intFromFloat(y), - }; - }; - self.syncBlur() catch |err| { - log.err("failed to synchronize blur={}", .{err}); - }; - self.syncDecorations() catch |err| { - log.err("failed to synchronize decorations={}", .{err}); - }; - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self.apprt_window.getWindowDecoration()) { - .auto, .client => true, - .server, .none => false, - }; - } - - fn syncBlur(self: *Window) !void { - // FIXME: This doesn't currently factor in rounded corners on Adwaita, - // which means that the blur region will grow slightly outside of the - // window borders. Unfortunately, actually calculating the rounded - // region can be quite complex without having access to existing APIs - // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) - // and I think it's not really noticeable enough to justify the effort. - // (Wayland also has this visual artifact anyway...) - - const gtk_widget = self.apprt_window.as(gtk.Widget); - const config = if (self.apprt_window.getConfig()) |v| v.get() else 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"; - 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); - } - } - - fn syncDecorations(self: *Window) !void { - var hints: MotifWMHints = .{}; - - self.getWindowProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{}, - &hints, - ) catch |err| switch (err) { - // motif_wm_hints is already initialized, so this is fine - error.PropertyNotFound => {}, - - error.RequestFailed, - error.PropertyTypeMismatch, - error.PropertyFormatMismatch, - => return err, - }; - - hints.flags.decorations = true; - hints.decorations.all = switch (self.apprt_window.getWindowDecoration()) { - .server => true, - .auto, .client, .none => false, - }; - - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - var buf: [64]u8 = undefined; - const window_id = try std.fmt.bufPrint( - &buf, - "{}", - .{self.x11_surface.getXid()}, - ); - - try env.put("WINDOWID", window_id); - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - self.x11_surface.setUrgencyHint(@intFromBool(urgent)); - } - - fn getWindowProperty( - self: *Window, - comptime T: type, - name: c.Atom, - typ: c.Atom, - comptime format: PropertyFormat, - options: struct { - offset: c_long = 0, - length: c_long = std.math.maxInt(c_long), - delete: bool = false, - }, - result: *T, - ) GetWindowPropertyError!void { - // FIXME: Maybe we should switch to libxcb one day. - // Sounds like a much better idea than whatever this is - var actual_type_return: c.Atom = undefined; - var actual_format_return: c_int = undefined; - var nitems_return: c_ulong = undefined; - var bytes_after_return: c_ulong = undefined; - var prop_return: ?format.bufferType() = null; - - const code = c.XGetWindowProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - options.offset, - options.length, - @intFromBool(options.delete), - typ, - &actual_type_return, - &actual_format_return, - &nitems_return, - &bytes_after_return, - @ptrCast(&prop_return), - ); - if (code != c.Success) return error.RequestFailed; - - if (actual_type_return == c.None) return error.PropertyNotFound; - if (typ != actual_type_return) return error.PropertyTypeMismatch; - if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch; - - const data_ptr: *T = @ptrCast(prop_return); - result.* = data_ptr.*; - _ = c.XFree(prop_return); - } - - fn changeProperty( - self: *Window, - comptime T: type, - name: c.Atom, - typ: c.Atom, - comptime format: PropertyFormat, - options: struct { - mode: PropertyChangeMode, - }, - value: *T, - ) X11Error!void { - const data: format.bufferType() = @ptrCast(value); - - const status = c.XChangeProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - typ, - @intFromEnum(format), - @intFromEnum(options.mode), - data, - @divExact(@sizeOf(T), @sizeOf(format.elemType())), - ); - - // For some godforsaken reason Xlib alternates between - // error values (0 = success) and booleans (1 = success), and they look exactly - // the same in the signature (just `int`, since Xlib is written in C89)... - if (status == 0) return error.RequestFailed; - } - - fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { - const status = c.XDeleteProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - ); - if (status == 0) return error.RequestFailed; - } -}; - -const X11Error = error{ - RequestFailed, -}; - -const GetWindowPropertyError = X11Error || error{ - PropertyNotFound, - PropertyTypeMismatch, - PropertyFormatMismatch, -}; - -const Atoms = struct { - kde_blur: c.Atom, - motif_wm_hints: c.Atom, - - fn init(display: *gdk_x11.X11Display) Atoms { - return .{ - .kde_blur = gdk_x11.x11GetXatomByNameForDisplay( - display, - "_KDE_NET_WM_BLUR_BEHIND_REGION", - ), - .motif_wm_hints = gdk_x11.x11GetXatomByNameForDisplay( - display, - "_MOTIF_WM_HINTS", - ), - }; - } -}; - -const PropertyChangeMode = enum(c_int) { - replace = c.PropModeReplace, - prepend = c.PropModePrepend, - append = c.PropModeAppend, -}; - -const PropertyFormat = enum(c_int) { - _8 = 8, - _16 = 16, - _32 = 32, - - fn elemType(comptime self: PropertyFormat) type { - return switch (self) { - ._8 => c_char, - ._16 => c_int, - ._32 => c_long, - }; - } - - fn bufferType(comptime self: PropertyFormat) type { - // The buffer type has to be a multi-pointer to bytes - // *aligned to the element type* (very important, - // otherwise you'll read garbage!) - // - // I know this is really ugly. X11 is ugly. I consider it apropos. - return [*]align(@alignOf(self.elemType())) u8; - } -}; - -const Region = extern struct { - x: c_long = 0, - y: c_long = 0, - width: c_long = 0, - height: c_long = 0, -}; - -// See Xm/MwmUtil.h, packaged with the Motif Window Manager -const MotifWMHints = extern struct { - flags: packed struct(c_ulong) { - _pad: u1 = 0, - decorations: bool = false, - - // We don't really care about the other flags - _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0, - } = .{}, - functions: c_ulong = 0, - decorations: packed struct(c_ulong) { - all: bool = false, - - // We don't really care about the other flags - _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0, - } = .{}, - input_mode: c_long = 0, - status: c_ulong = 0, -}; diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 3193065c4..212892094 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -1,12 +1,15 @@ -//! Application runtime that uses GTK4. +const internal_os = @import("../os/main.zig"); +// The required comptime API for any apprt. pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); -pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; +pub const resourcesDir = internal_os.resourcesDir; + +// The exported API, custom for the apprt. +pub const class = @import("gtk/class.zig"); +pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); - - _ = @import("gtk/inspector.zig"); - _ = @import("gtk/key.zig"); + _ = @import("gtk/ext.zig"); } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ee5f3eb96..4d2006fbb 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1,59 +1,23 @@ -/// App is the entrypoint for the application. This is called after all -/// of the runtime-agnostic initialization is complete and we're ready -/// to start. -/// -/// There is only ever one App instance per process. This is because most -/// application frameworks also have this restriction so it simplifies -/// the assumptions. -/// -/// In GTK, the App contains the primary GApplication and GMainContext -/// (event loop) along with any global app state. +/// This is the main entrypoint to the apprt for Ghostty. Ghostty will +/// initialize this in main to start the application.. const App = @This(); -const adw = @import("adw"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; -const Allocator = std.mem.Allocator; const builtin = @import("builtin"); -const build_config = @import("../../build_config.zig"); -const xev = @import("../../global.zig").xev; -const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); -const input = @import("../../input.zig"); const internal_os = @import("../../os/main.zig"); -const systemd = @import("../../os/systemd.zig"); -const terminal = @import("../../terminal/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); -const CoreSurface = @import("../../Surface.zig"); -const ipc = @import("ipc.zig"); -const cgroup = @import("cgroup.zig"); +const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); -const Window = @import("Window.zig"); -const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig"); -const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const GlobalShortcuts = @import("GlobalShortcuts.zig"); -const Split = @import("Split.zig"); -const inspector = @import("inspector.zig"); -const key = @import("key.zig"); -const winprotopkg = @import("winproto.zig"); const gtk_version = @import("gtk_version.zig"); const adw_version = @import("adw_version.zig"); - -pub const c = @cImport({ - // generated header files - @cInclude("ghostty_resources.h"); -}); +const ipcNewWindow = @import("ipc/new_window.zig").newWindow; const log = std.log.scoped(.gtk); @@ -62,493 +26,59 @@ const log = std.log.scoped(.gtk); /// because GTK's `GLArea` does not support drawing from a different thread. pub const must_draw_from_app_thread = true; -pub const Options = struct {}; +/// GTK application ID +pub const application_id = switch (builtin.mode) { + .Debug, .ReleaseSafe => "com.mitchellh.ghostty-debug", + .ReleaseFast, .ReleaseSmall => "com.mitchellh.ghostty", +}; -core_app: *CoreApp, -config: Config, +/// GTK object path +pub const object_path = switch (builtin.mode) { + .Debug, .ReleaseSafe => "/com/mitchellh/ghostty_debug", + .ReleaseFast, .ReleaseSmall => "/com/mitchellh/ghostty", +}; -app: *adw.Application, -ctx: *glib.MainContext, +/// The GObject Application instance +app: *Application, -/// State and logic for the underlying windowing protocol. -winproto: winprotopkg.App, +pub fn init( + self: *App, + core_app: *CoreApp, -/// True if the app was launched with single instance mode. -single_instance: bool, - -/// The "none" cursor. We use one that is shared across the entire app. -cursor_none: ?*gdk.Cursor, - -/// The clipboard confirmation window, if it is currently open. -clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, - -/// The config errors dialog, if it is currently open. -config_errors_dialog: ?ConfigErrorsDialog = null, - -/// The window containing the quick terminal. -/// Null when never initialized. -quick_terminal: ?*Window = null, - -/// This is set to false when the main loop should exit. -running: bool = true, - -/// The base path of the transient cgroup used to put all surfaces -/// into their own cgroup. This is only set if cgroups are enabled -/// and initialization was successful. -transient_cgroup_base: ?[]const u8 = null, - -/// CSS Provider for any styles based on ghostty configuration values -css_provider: *gtk.CssProvider, - -/// Providers for loading custom stylesheets defined by user -custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{}, - -global_shortcuts: ?GlobalShortcuts, - -/// The timer used to quit the application after the last window is closed. -quit_timer: union(enum) { - off: void, - active: c_uint, - expired: void, -} = .{ .off = {} }, - -pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void { + // Required by the apprt interface but we don't use it. + opts: struct {}, +) !void { _ = opts; - // Log our GTK version - gtk_version.logVersion(); - - // log the adwaita version - adw_version.logVersion(); - - // Set gettext global domain to be our app so that our unqualified - // translations map to our translations. - try internal_os.i18n.initGlobalDomain(); - - // Load our configuration - var config = try Config.load(core_app.alloc); - errdefer config.deinit(); - - // If we had configuration errors, then log them. - if (!config._diagnostics.empty()) { - var buf = std.ArrayList(u8).init(core_app.alloc); - defer buf.deinit(); - for (config._diagnostics.items()) |diag| { - try diag.write(buf.writer()); - log.warn("configuration error: {s}", .{buf.items}); - buf.clearRetainingCapacity(); - } - - // If we have any CLI errors, exit. - if (config._diagnostics.containsLocation(.cli)) { - log.warn("CLI errors detected, exiting", .{}); - std.posix.exit(1); - } - } - - // Setup our event loop backend - if (config.@"async-backend" != .auto) { - const result: bool = switch (config.@"async-backend") { - .auto => unreachable, - .epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false, - .io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false, - }; - - if (result) { - log.info( - "libxev manual backend={s}", - .{@tagName(xev.backend)}, - ); - } else { - log.warn( - "libxev manual backend failed, using default={s}", - .{@tagName(xev.backend)}, - ); - } - } - - var gdk_debug: struct { - /// output OpenGL debug information - opengl: bool = false, - /// disable GLES, Ghostty can't use GLES - @"gl-disable-gles": bool = false, - // GTK's new renderer can cause blurry font when using fractional scaling. - @"gl-no-fractional": bool = false, - /// Disabling Vulkan can improve startup times by hundreds of - /// milliseconds on some systems. We don't use Vulkan so we can just - /// disable it. - @"vulkan-disable": bool = false, - } = .{ - .opengl = config.@"gtk-opengl-debug", - }; - - var gdk_disable: struct { - @"gles-api": bool = false, - /// current gtk implementation for color management is not good enough. - /// see: https://bugs.kde.org/show_bug.cgi?id=495647 - /// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864 - @"color-mgmt": bool = true, - /// Disabling Vulkan can improve startup times by hundreds of - /// milliseconds on some systems. We don't use Vulkan so we can just - /// disable it. - vulkan: bool = false, - } = .{}; - - environment: { - if (gtk_version.runtimeAtLeast(4, 18, 0)) { - gdk_disable.@"color-mgmt" = false; - } - - if (gtk_version.runtimeAtLeast(4, 16, 0)) { - // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. - // For the remainder of "why" see the 4.14 comment below. - gdk_disable.@"gles-api" = true; - gdk_disable.vulkan = true; - break :environment; - } - if (gtk_version.runtimeAtLeast(4, 14, 0)) { - // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. - // Older versions of GTK do not support these values so it is safe - // to always set this. Forwards versions are uncertain so we'll have - // to reassess... - // - // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 - gdk_debug.@"gl-disable-gles" = true; - gdk_debug.@"vulkan-disable" = true; - - if (gtk_version.runtimeUntil(4, 17, 5)) { - // Removed at GTK v4.17.5 - gdk_debug.@"gl-no-fractional" = true; - } - break :environment; - } - // Versions prior to 4.14 are a bit of an unknown for Ghostty. It - // is an environment that isn't tested well and we don't have a - // good understanding of what we may need to do. - gdk_debug.@"vulkan-disable" = true; - } - - { - var buf: [128]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); - var first: bool = true; - inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| { - if (@field(gdk_debug, field.name)) { - if (!first) try writer.writeAll(","); - try writer.writeAll(field.name); - first = false; - } - } - try writer.writeByte(0); - const value = fmt.getWritten(); - log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); - } - - { - var buf: [128]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); - var first: bool = true; - inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| { - if (@field(gdk_disable, field.name)) { - if (!first) try writer.writeAll(","); - try writer.writeAll(field.name); - first = false; - } - } - try writer.writeByte(0); - const value = fmt.getWritten(); - log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); - } - - adw.init(); - - const display: *gdk.Display = gdk.Display.getDefault() orelse { - // I'm unsure of any scenario where this happens. Because we don't - // want to litter null checks everywhere, we just exit here. - log.warn("gdk display is null, exiting", .{}); - std.posix.exit(1); - }; - - // The "none" cursor is used for hiding the cursor - const cursor_none = gdk.Cursor.newFromName("none", null); - errdefer if (cursor_none) |cursor| cursor.unref(); - - const single_instance = switch (config.@"gtk-single-instance") { - .true => true, - .false => false, - .desktop => switch (config.@"launched-from".?) { - .desktop, .systemd, .dbus => true, - .cli => false, - }, - }; - - // Setup the flags for our application. - const app_flags: gio.ApplicationFlags = app_flags: { - var flags: gio.ApplicationFlags = .flags_default_flags; - if (!single_instance) flags.non_unique = true; - break :app_flags flags; - }; - - // Our app ID determines uniqueness and maps to our desktop file. - // We append "-debug" to the ID if we're in debug mode so that we - // can develop Ghostty in Ghostty. - const app_id: [:0]const u8 = app_id: { - if (config.class) |class| { - if (gio.Application.idIsValid(class) != 0) { - break :app_id class; - } else { - log.warn("invalid 'class' in config, ignoring", .{}); - } - } - - const default_id = comptime build_config.bundle_id; - break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; - }; - - // Create our GTK Application which encapsulates our process. - log.debug("creating GTK application id={s} single-instance={}", .{ - app_id, - single_instance, - }); - - // Using an AdwApplication lets us use Adwaita widgets and access things - // such as the color scheme. - const adw_app = adw.Application.new( - app_id.ptr, - app_flags, - ); - errdefer adw_app.unref(); - - const style_manager = adw_app.getStyleManager(); - style_manager.setColorScheme( - switch (config.@"window-theme") { - .auto, .ghostty => auto: { - const lum = config.background.toTerminalRGB().perceivedLuminance(); - break :auto if (lum > 0.5) - .prefer_light - else - .prefer_dark; - }, - .system => .prefer_light, - .dark => .force_dark, - .light => .force_light, - }, - ); - - const gio_app = adw_app.as(gio.Application); - - // force the resource path to a known value so that it doesn't depend on - // the app id and load in compiled resources - gio_app.setResourceBasePath("/com/mitchellh/ghostty"); - gio.resourcesRegister(@ptrCast(@alignCast(c.ghostty_get_resource() orelse { - log.err("unable to load resources", .{}); - return error.GtkNoResources; - }))); - - // The `activate` signal is used when Ghostty is first launched and when a - // secondary Ghostty is launched and requests a new window. - _ = gio.Application.signals.activate.connect( - adw_app, - *CoreApp, - gtkActivate, - core_app, - .{}, - ); - - // Other signals - _ = gtk.Application.signals.window_added.connect( - adw_app, - *CoreApp, - gtkWindowAdded, - core_app, - .{}, - ); - _ = gtk.Application.signals.window_removed.connect( - adw_app, - *CoreApp, - gtkWindowRemoved, - core_app, - .{}, - ); - - // Setup a listener for SIGUSR2 to reload the configuration. - _ = glib.unixSignalAdd( - std.posix.SIG.USR2, - sigusr2, - self, - ); - - // We don't use g_application_run, we want to manually control the - // loop so we have to do the same things the run function does: - // https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533 - const ctx = glib.MainContext.default(); - if (glib.MainContext.acquire(ctx) == 0) return error.GtkContextAcquireFailed; - errdefer glib.MainContext.release(ctx); - - var err_: ?*glib.Error = null; - if (gio_app.register( - null, - &err_, - ) == 0) { - if (err_) |err| { - log.warn("error registering application: {s}", .{err.f_message orelse "(unknown)"}); - err.free(); - } - return error.GtkApplicationRegisterFailed; - } - - // Setup our windowing protocol logic - var winproto_app = try winprotopkg.App.init( - core_app.alloc, - display, - app_id, - &config, - ); - errdefer winproto_app.deinit(core_app.alloc); - log.debug("windowing protocol={s}", .{@tagName(winproto_app)}); - - // This just calls the `activate` signal but its part of the normal startup - // routine so we just call it, but only if the config allows it (this allows - // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. - // - // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") switch (config.@"launched-from".?) { - .desktop, .cli => gio_app.activate(), - .dbus, .systemd => {}, - }; - - // Internally, GTK ensures that only one instance of this provider exists in the provider list - // for the display. - const css_provider = gtk.CssProvider.new(); - gtk.StyleContext.addProviderForDisplay( - display, - css_provider.as(gtk.StyleProvider), - gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, - ); - - self.* = .{ - .core_app = core_app, - .app = adw_app, - .config = config, - .ctx = ctx, - .cursor_none = cursor_none, - .winproto = winproto_app, - .single_instance = single_instance, - // If we are NOT the primary instance, then we never want to run. - // This means that another instance of the GTK app is running and - // our "activate" call above will open a window. - .running = gio_app.getIsRemote() == 0, - .css_provider = css_provider, - .global_shortcuts = .init(core_app.alloc, gio_app), - }; + const app: *Application = try .new(self, core_app); + errdefer app.unref(); + self.* = .{ .app = app }; + return; +} + +pub fn run(self: *App) !void { + try self.app.run(); } -// Terminate the application. The application will not be restarted after -// this so all global state can be cleaned up. pub fn terminate(self: *App) void { - gio.Settings.sync(); - while (glib.MainContext.iteration(self.ctx, 0) != 0) {} - glib.MainContext.release(self.ctx); - self.app.unref(); - - if (self.cursor_none) |cursor| cursor.unref(); - if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); - - for (self.custom_css_providers.items) |provider| { - provider.unref(); - } - self.custom_css_providers.deinit(self.core_app.alloc); - - self.winproto.deinit(self.core_app.alloc); - - if (self.global_shortcuts) |*shortcuts| shortcuts.deinit(); - - self.config.deinit(); + // We force deinitialize the app. We don't unref because other things + // tend to have a reference at this point, so this just forces the + // disposal now. + self.app.deinit(); +} + +/// Called by CoreApp to wake up the event loop. +pub fn wakeup(self: *App) void { + self.app.wakeup(); } -/// Perform a given action. Returns `true` if the action was able to be -/// performed, `false` otherwise. pub fn performAction( self: *App, target: apprt.Target, comptime action: apprt.Action.Key, value: apprt.Action.Value(action), ) !bool { - switch (action) { - .quit => self.quit(), - .new_window => _ = try self.newWindow(switch (target) { - .app => null, - .surface => |v| v, - }), - .close_window => return try self.closeWindow(target), - .toggle_maximize => self.toggleMaximize(target), - .toggle_fullscreen => self.toggleFullscreen(target, value), - .new_tab => try self.newTab(target), - .close_tab => return try self.closeTab(target, value), - .goto_tab => return self.gotoTab(target, value), - .move_tab => self.moveTab(target, value), - .new_split => try self.newSplit(target, value), - .resize_split => self.resizeSplit(target, value), - .equalize_splits => self.equalizeSplits(target), - .goto_split => return self.gotoSplit(target, value), - .open_config => return self.openConfig(), - .config_change => self.configChange(target, value.config), - .reload_config => try self.reloadConfig(target, value), - .inspector => self.controlInspector(target, value), - .show_gtk_inspector => self.showGTKInspector(), - .desktop_notification => self.showDesktopNotification(target, value), - .set_title => try self.setTitle(target, value), - .pwd => try self.setPwd(target, value), - .present_terminal => self.presentTerminal(target), - .initial_size => try self.setInitialSize(target, value), - .size_limit => try self.setSizeLimit(target, value), - .mouse_visibility => self.setMouseVisibility(target, value), - .mouse_shape => try self.setMouseShape(target, value), - .mouse_over_link => self.setMouseOverLink(target, value), - .toggle_tab_overview => self.toggleTabOverview(target), - .toggle_split_zoom => self.toggleSplitZoom(target), - .toggle_window_decorations => self.toggleWindowDecorations(target), - .quit_timer => self.quitTimer(value), - .prompt_title => try self.promptTitle(target), - .toggle_quick_terminal => return try self.toggleQuickTerminal(), - .secure_input => self.setSecureInput(target, value), - .ring_bell => try self.ringBell(target), - .toggle_command_palette => try self.toggleCommandPalette(target), - .open_url => self.openUrl(value), - .show_child_exited => return try self.showChildExited(target, value), - .progress_report => return try self.handleProgressReport(target, value), - .render => self.render(target), - - // Unimplemented - .close_all_windows, - .float_window, - .toggle_visibility, - .cell_size, - .key_sequence, - .render_inspector, - .renderer_health, - .color_change, - .reset_window_size, - .check_for_updates, - .undo, - .redo, - .show_on_screen_keyboard, - => { - log.warn("unimplemented action={}", .{action}); - return false; - }, - } - - // We can assume it was handled because all unknown/unimplemented actions - // are caught above. - return true; + return try self.app.performAction(target, action, value); } /// Send the given IPC to a running Ghostty. Returns `true` if the action was @@ -562,1339 +92,13 @@ pub fn performIpc( target: apprt.ipc.Target, comptime action: apprt.ipc.Action.Key, value: apprt.ipc.Action.Value(action), -) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { +) !bool { switch (action) { - .new_window => return try ipc.openNewWindow(alloc, target, value), - } -} - -fn newTab(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "new_tab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - try window.newTab(v); - }, - } -} - -fn closeTab(_: *App, target: apprt.Target, value: apprt.Action.Value(.close_tab)) !bool { - switch (target) { - .app => return false, - .surface => |v| { - const tab = v.rt_surface.container.tab() orelse { - log.info( - "close_tab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return false; - }; - - switch (value) { - .this => { - tab.closeWithConfirmation(); - return true; - }, - .other => { - log.warn("close-tab:other is not implemented", .{}); - return false; - }, - } - }, - } -} - -fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) bool { - switch (target) { - .app => return false, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "gotoTab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return false; - }; - - return switch (tab) { - .previous => window.gotoPreviousTab(v.rt_surface), - .next => window.gotoNextTab(v.rt_surface), - .last => window.gotoLastTab(), - else => window.gotoTab(@intCast(@intFromEnum(tab))), - }; - }, - } -} - -fn moveTab(_: *App, target: apprt.Target, move_tab: apprt.action.MoveTab) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "moveTab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.moveTab(v.rt_surface, @intCast(move_tab.amount)); - }, - } -} - -fn newSplit( - self: *App, - target: apprt.Target, - direction: apprt.action.SplitDirection, -) !void { - switch (target) { - .app => {}, - .surface => |v| { - const alloc = self.core_app.alloc; - _ = try Split.create(alloc, v.rt_surface, direction); - }, - } -} - -fn equalizeSplits(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const tab = v.rt_surface.container.tab() orelse return; - const top_split = switch (tab.elem) { - .split => |s| s, - else => return, - }; - _ = top_split.equalize(); - }, - } -} - -fn gotoSplit( - _: *const App, - target: apprt.Target, - direction: apprt.action.GotoSplit, -) bool { - switch (target) { - .app => return false, - .surface => |v| { - const s = v.rt_surface.container.split() orelse return false; - const map = s.directionMap(switch (v.rt_surface.container) { - .split_tl => .top_left, - .split_br => .bottom_right, - .none, .tab_ => unreachable, - }); - const surface_ = map.get(direction) orelse return false; - if (surface_) |surface| { - surface.grabFocus(); - return true; - } - return false; - }, - } -} - -fn resizeSplit( - _: *const App, - target: apprt.Target, - resize: apprt.action.ResizeSplit, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const s = v.rt_surface.container.firstSplitWithOrientation( - Split.Orientation.fromResizeDirection(resize.direction), - ) orelse return; - s.moveDivider(resize.direction, resize.amount); - }, - } -} - -fn presentTerminal( - _: *const App, - target: apprt.Target, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.present(), - } -} - -fn controlInspector( - _: *const App, - target: apprt.Target, - mode: apprt.action.Inspector, -) void { - const surface: *Surface = switch (target) { - .app => return, - .surface => |v| v.rt_surface, - }; - - surface.controlInspector(mode); -} - -fn showGTKInspector( - _: *const App, -) void { - gtk.Window.setInteractiveDebugging(@intFromBool(true)); -} - -fn toggleMaximize(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleMaximize invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - window.toggleMaximize(); - }, - } -} - -fn toggleFullscreen( - _: *App, - target: apprt.Target, - _: apprt.action.Fullscreen, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleFullscreen invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleFullscreen(); - }, - } -} - -fn toggleTabOverview(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleTabOverview invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleTabOverview(); - }, - } -} - -fn toggleSplitZoom(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |surface| surface.rt_surface.toggleSplitZoom(), - } -} - -fn toggleWindowDecorations( - _: *App, - target: apprt.Target, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleWindowDecorations invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleWindowDecorations(); - }, - } -} - -fn toggleQuickTerminal(self: *App) !bool { - if (self.quick_terminal) |qt| { - qt.toggleVisibility(); - return true; - } - - if (!self.winproto.supportsQuickTerminal()) return false; - - const qt = Window.create(self.core_app.alloc, self) catch |err| { - log.err("failed to initialize quick terminal={}", .{err}); - return true; - }; - self.quick_terminal = qt; - - // The setup has to happen *before* the window-specific winproto is - // initialized, so we need to initialize it through the app winproto - try self.winproto.initQuickTerminal(qt); - - // Finalize creating the quick terminal - try qt.newTab(null); - qt.present(); - return true; -} - -fn ringBell(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |surface| try surface.rt_surface.ringBell(), - } -} - -fn toggleCommandPalette(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |surface| { - const window = surface.rt_surface.container.window() orelse { - log.info( - "toggleCommandPalette invalid for container={s}", - .{@tagName(surface.rt_surface.container)}, - ); - return; - }; - - window.toggleCommandPalette(); - }, - } -} - -fn showChildExited(_: *App, target: apprt.Target, value: apprt.surface.Message.ChildExited) error{}!bool { - switch (target) { - .app => return false, - .surface => |surface| return try surface.rt_surface.showChildExited(value), - } -} - -/// Show a native GUI element to indicate the progress of a TUI operation. -fn handleProgressReport(_: *App, target: apprt.Target, value: terminal.osc.Command.ProgressReport) error{}!bool { - switch (target) { - .app => return false, - .surface => |surface| return try surface.rt_surface.progress_bar.handleProgressReport(value), - } -} - -fn render(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.redraw(), - } -} - -fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { - switch (mode) { - .start => self.startQuitTimer(), - .stop => self.stopQuitTimer(), - } -} - -fn promptTitle(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |v| { - try v.rt_surface.promptTitle(); - }, - } -} - -fn setTitle( - _: *App, - target: apprt.Target, - title: apprt.action.SetTitle, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setTitle(title.title, .terminal), - } -} - -fn setPwd( - _: *App, - target: apprt.Target, - pwd: apprt.action.Pwd, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setPwd(pwd.pwd), - } -} - -fn setMouseVisibility( - _: *App, - target: apprt.Target, - visibility: apprt.action.MouseVisibility, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.setMouseVisibility(switch (visibility) { - .visible => true, - .hidden => false, - }), - } -} - -fn setMouseShape( - _: *App, - target: apprt.Target, - shape: terminal.MouseShape, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setMouseShape(shape), - } -} - -fn setMouseOverLink( - _: *App, - target: apprt.Target, - value: apprt.action.MouseOverLink, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.mouseOverLink(if (value.url.len > 0) - value.url - else - null), - } -} - -fn setInitialSize( - _: *App, - target: apprt.Target, - value: apprt.action.InitialSize, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setInitialWindowSize( - value.width, - value.height, - ), - } -} - -fn setSizeLimit( - _: *App, - target: apprt.Target, - value: apprt.action.SizeLimit, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setSizeLimits(.{ - .width = value.min_width, - .height = value.min_height, - }, if (value.max_width > 0) .{ - .width = value.max_width, - .height = value.max_height, - } else null), - } -} - -fn showDesktopNotification( - self: *App, - target: apprt.Target, - n: apprt.action.DesktopNotification, -) void { - // Set a default title if we don't already have one - const t = switch (n.title.len) { - 0 => "Ghostty", - else => n.title, - }; - - const notification = gio.Notification.new(t); - defer notification.unref(); - notification.setBody(n.body); - - const icon = gio.ThemedIcon.new("com.mitchellh.ghostty"); - defer icon.unref(); - notification.setIcon(icon.as(gio.Icon)); - - const pointer = glib.Variant.newUint64(switch (target) { - .app => 0, - .surface => |v| @intFromPtr(v), - }); - notification.setDefaultActionAndTargetValue("app.present-surface", pointer); - - const gio_app = self.app.as(gio.Application); - - // We set the notification ID to the body content. If the content is the - // same, this notification may replace a previous notification - gio_app.sendNotification(n.body, notification); -} - -fn configChange( - self: *App, - target: apprt.Target, - new_config: *const Config, -) void { - switch (target) { - .surface => |surface| surface: { - surface.rt_surface.updateConfig(new_config) catch |err| { - log.err("unable to update surface config: {}", .{err}); - }; - const window = surface.rt_surface.container.window() orelse break :surface; - window.updateConfig(new_config) catch |err| { - log.warn("error updating config for window err={}", .{err}); - }; - }, - - .app => { - // We clone (to take ownership) and update our configuration. - if (new_config.clone(self.core_app.alloc)) |config_clone| { - self.config.deinit(); - self.config = config_clone; - } else |err| { - log.warn("error cloning configuration err={}", .{err}); - } - - // App changes needs to show a toast that our configuration - // has reloaded. - const window = window: { - if (self.core_app.focusedSurface()) |core_surface| { - const surface = core_surface.rt_surface; - if (surface.container.window()) |window| { - window.onConfigReloaded(); - break :window window; - } - } - break :window null; - }; - - self.syncConfigChanges(window) catch |err| { - log.warn("error handling configuration changes err={}", .{err}); - }; - }, - } -} - -pub fn reloadConfig( - self: *App, - target: apprt.action.Target, - opts: apprt.action.ReloadConfig, -) !void { - // Tell systemd that reloading has started. - systemd.notify.reloading(); - - // When we exit this function tell systemd that reloading has finished. - defer systemd.notify.ready(); - - if (opts.soft) { - switch (target) { - .app => try self.core_app.updateConfig(self, &self.config), - .surface => |core_surface| try core_surface.updateConfig( - &self.config, - ), - } - return; - } - - // Load our configuration - var config = try Config.load(self.core_app.alloc); - errdefer config.deinit(); - - // Call into our app to update - switch (target) { - .app => try self.core_app.updateConfig(self, &config), - .surface => |core_surface| try core_surface.updateConfig(&config), - } - - // Update the existing config, be sure to clean up the old one. - self.config.deinit(); - self.config = config; -} - -/// Call this anytime the configuration changes. -fn syncConfigChanges(self: *App, window: ?*Window) !void { - ConfigErrorsDialog.maybePresent(self, window); - try self.syncActionAccelerators(); - - if (self.global_shortcuts) |*shortcuts| { - shortcuts.refreshSession(self) catch |err| { - log.warn("failed to refresh global shortcuts={}", .{err}); - }; - } - - // Load our runtime and custom CSS. If this fails then our window is just stuck - // with the old CSS but we don't want to fail the entire sync operation. - self.loadRuntimeCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "out of memory loading runtime CSS, no runtime CSS applied", - .{}, - ), - }; - self.loadCustomCss() catch |err| { - log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); - }; -} - -fn syncActionAccelerators(self: *App) !void { - try self.syncActionAccelerator("app.quit", .{ .quit = {} }); - try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); - try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); - try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); - try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); - try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); - try self.syncActionAccelerator("win.close", .{ .close_window = {} }); - try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); - try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); - try self.syncActionAccelerator("win.close-tab", .{ .close_tab = .this }); - try self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); - try self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); - try self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); - try self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); - try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); - try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); - try self.syncActionAccelerator("win.reset", .{ .reset = {} }); - try self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); - try self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); -} - -fn syncActionAccelerator( - self: *App, - gtk_action: [:0]const u8, - action: input.Binding.Action, -) !void { - const gtk_app = self.app.as(gtk.Application); - - // Reset it initially - const zero = [_:null]?[*:0]const u8{}; - gtk_app.setAccelsForAction(gtk_action, &zero); - - const trigger = self.config.keybind.set.getTrigger(action) orelse return; - var buf: [256]u8 = undefined; - const accel = try key.accelFromTrigger(&buf, trigger) orelse return; - const accels = [_:null]?[*:0]const u8{accel}; - - gtk_app.setAccelsForAction(gtk_action, &accels); -} - -fn loadRuntimeCss( - self: *const App, -) Allocator.Error!void { - const alloc = self.core_app.alloc; - - var buf: std.ArrayListUnmanaged(u8) = .empty; - defer buf.deinit(alloc); - - const writer = buf.writer(alloc); - - const config: *const Config = &self.config; - const window_theme = config.@"window-theme"; - const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; - const headerbar_background = config.@"window-titlebar-background" orelse config.background; - const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; - - try writer.print( - \\widget.unfocused-split {{ - \\ opacity: {d:.2}; - \\ background-color: rgb({d},{d},{d}); - \\}} - , .{ - 1.0 - config.@"unfocused-split-opacity", - unfocused_fill.r, - unfocused_fill.g, - unfocused_fill.b, - }); - - if (config.@"split-divider-color") |color| { - try writer.print( - \\.terminal-window .notebook separator {{ - \\ color: rgb({[r]d},{[g]d},{[b]d}); - \\ background: rgb({[r]d},{[g]d},{[b]d}); - \\}} - , .{ - .r = color.r, - .g = color.g, - .b = color.b, - }); - } - - if (config.@"window-title-font-family") |font_family| { - try writer.print( - \\.window headerbar {{ - \\ font-family: "{[font_family]s}"; - \\}} - , .{ .font_family = font_family }); - } - - if (gtk_version.runtimeAtLeast(4, 16, 0)) { - switch (window_theme) { - .ghostty => try writer.print( - \\:root {{ - \\ --ghostty-fg: rgb({d},{d},{d}); - \\ --ghostty-bg: rgb({d},{d},{d}); - \\ --headerbar-fg-color: var(--ghostty-fg); - \\ --headerbar-bg-color: var(--ghostty-bg); - \\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha); - \\ --overview-fg-color: var(--ghostty-fg); - \\ --overview-bg-color: var(--ghostty-bg); - \\ --popover-fg-color: var(--ghostty-fg); - \\ --popover-bg-color: var(--ghostty-bg); - \\ --window-fg-color: var(--ghostty-fg); - \\ --window-bg-color: var(--ghostty-bg); - \\}} - \\windowhandle {{ - \\ background-color: var(--headerbar-bg-color); - \\ color: var(--headerbar-fg-color); - \\}} - \\windowhandle:backdrop {{ - \\ background-color: var(--headerbar-backdrop-color); - \\}} - , .{ - headerbar_foreground.r, - headerbar_foreground.g, - headerbar_foreground.b, - headerbar_background.r, - headerbar_background.g, - headerbar_background.b, - }), - else => {}, - } - } else { - try writer.print( - \\window.window-theme-ghostty .top-bar, - \\window.window-theme-ghostty .bottom-bar, - \\window.window-theme-ghostty box > tabbar {{ - \\ background-color: rgb({d},{d},{d}); - \\ color: rgb({d},{d},{d}); - \\}} - , .{ - headerbar_background.r, - headerbar_background.g, - headerbar_background.b, - headerbar_foreground.r, - headerbar_foreground.g, - headerbar_foreground.b, - }); - } - - const data = try alloc.dupeZ(u8, buf.items); - defer alloc.free(data); - - // Clears any previously loaded CSS from this provider - loadCssProviderFromData(self.css_provider, data); -} - -fn loadCustomCss(self: *App) !void { - const alloc = self.core_app.alloc; - - const display = gdk.Display.getDefault() orelse { - log.warn("unable to get display", .{}); - return; - }; - - // unload the previously loaded style providers - for (self.custom_css_providers.items) |provider| { - gtk.StyleContext.removeProviderForDisplay( - display, - provider.as(gtk.StyleProvider), - ); - provider.unref(); - } - self.custom_css_providers.clearRetainingCapacity(); - - for (self.config.@"gtk-custom-css".value.items) |p| { - const path, const optional = switch (p) { - .optional => |path| .{ path, true }, - .required => |path| .{ path, false }, - }; - const file = std.fs.openFileAbsolute(path, .{}) catch |err| { - if (err != error.FileNotFound or !optional) { - log.err( - "error opening gtk-custom-css file {s}: {}", - .{ path, err }, - ); - } - continue; - }; - defer file.close(); - - log.info("loading gtk-custom-css path={s}", .{path}); - const contents = try file.reader().readAllAlloc( - self.core_app.alloc, - 5 * 1024 * 1024, // 5MB, - ); - defer alloc.free(contents); - - const data = try alloc.dupeZ(u8, contents); - defer alloc.free(data); - - const provider = gtk.CssProvider.new(); - loadCssProviderFromData(provider, data); - gtk.StyleContext.addProviderForDisplay( - display, - provider.as(gtk.StyleProvider), - gtk.STYLE_PROVIDER_PRIORITY_USER, - ); - - try self.custom_css_providers.append(self.core_app.alloc, provider); - } -} - -fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void { - if (gtk_version.atLeast(4, 12, 0)) { - const g_bytes = glib.Bytes.new(data.ptr, data.len); - defer g_bytes.unref(); - - provider.loadFromBytes(g_bytes); - } else { - provider.loadFromData(data, @intCast(data.len)); - } -} - -/// Called by CoreApp to wake up the event loop. -pub fn wakeup(_: App) void { - glib.MainContext.wakeup(null); -} - -/// Run the event loop. This doesn't return until the app exits. -pub fn run(self: *App) !void { - // Running will be false when we're not the primary instance and should - // exit (GTK single instance mode). If we're not running, we're done - // right away. - if (!self.running) return; - - // If we are running, then we proceed to setup our app. - - // Setup our cgroup configurations for our surfaces. - if (switch (self.config.@"linux-cgroup") { - .never => false, - .always => true, - .@"single-instance" => self.single_instance, - }) cgroup: { - const path = cgroup.init(self) catch |err| { - // If we can't initialize cgroups then that's okay. We - // want to continue to run so we just won't isolate surfaces. - // NOTE(mitchellh): do we want a config to force it? - log.warn( - "failed to initialize cgroups, terminals will not be isolated err={}", - .{err}, - ); - - // If we have hard fail enabled then we exit now. - if (self.config.@"linux-cgroup-hard-fail") { - log.err("linux-cgroup-hard-fail enabled, exiting", .{}); - return error.CgroupInitFailed; - } - - break :cgroup; - }; - - log.info("cgroup isolation enabled base={s}", .{path}); - self.transient_cgroup_base = path; - } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); - - // Setup color scheme notifications - const style_manager: *adw.StyleManager = self.app.getStyleManager(); - _ = gobject.Object.signals.notify.connect( - style_manager, - *App, - adwNotifyDark, - self, - .{ - .detail = "dark", - }, - ); - - // Make an initial request to set up the color scheme - const light = style_manager.getDark() == 0; - self.colorSchemeEvent(if (light) .light else .dark); - - // Setup our actions - self.initActions(); - - // On startup, we want to check for configuration errors right away - // so we can show our error window. We also need to setup other initial - // state. - self.syncConfigChanges(null) catch |err| { - log.warn("error handling configuration changes err={}", .{err}); - }; - - // Tell systemd that we are ready. - systemd.notify.ready(); - - while (self.running) { - _ = glib.MainContext.iteration(self.ctx, 1); - - // Tick the terminal app and see if we should quit. - try self.core_app.tick(self); - - // Check if we must quit based on the current state. - const must_quit = q: { - // If we are configured to always stay running, don't quit. - if (!self.config.@"quit-after-last-window-closed") break :q false; - - // If the quit timer has expired, quit. - if (self.quit_timer == .expired) break :q true; - - // There's no quit timer running, or it hasn't expired, don't quit. - break :q false; - }; - - if (must_quit) self.quit(); - } -} - -// This timeout function is started when no surfaces are open. It can be -// cancelled if a new surface is opened before the timer expires. -pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *App = @ptrCast(@alignCast(ud)); - self.quit_timer = .{ .expired = {} }; - return 0; -} - -/// This will get called when there are no more open surfaces. -fn startQuitTimer(self: *App) void { - // Cancel any previous timer. - self.stopQuitTimer(); - - // This is a no-op unless we are configured to quit after last window is closed. - if (!self.config.@"quit-after-last-window-closed") return; - - if (self.config.@"quit-after-last-window-closed-delay") |v| { - // If a delay is configured, set a timeout function to quit after the delay. - self.quit_timer = .{ - .active = glib.timeoutAdd( - v.asMilliseconds(), - gtkQuitTimerExpired, - self, - ), - }; - } else { - // If no delay is configured, treat it as expired. - self.quit_timer = .{ .expired = {} }; - } -} - -/// This will get called when a new surface gets opened. -fn stopQuitTimer(self: *App) void { - switch (self.quit_timer) { - .off => {}, - .expired => self.quit_timer = .{ .off = {} }, - .active => |source| { - if (glib.Source.remove(source) == 0) { - log.warn("unable to remove quit timer source={d}", .{source}); - } - self.quit_timer = .{ .off = {} }; - }, + .new_window => return try ipcNewWindow(alloc, target, value), } } /// Redraw the inspector for the given surface. -pub fn redrawInspector(self: *App, surface: *Surface) void { - _ = self; - surface.queueInspectorRender(); -} - -/// Called by CoreApp to create a new window with a new surface. -fn newWindow(self: *App, parent_: ?*CoreSurface) !void { - const alloc = self.core_app.alloc; - - // Allocate a fixed pointer for our window. We try to minimize - // allocations but windows and other GUI requirements are so minimal - // compared to the steady-state terminal operation so we use heap - // allocation for this. - // - // The allocation is owned by the GtkWindow created. It will be - // freed when the window is closed. - var window = try Window.create(alloc, self); - - // Add our initial tab - try window.newTab(parent_); - - // Show the new window - window.present(); -} - -fn setSecureInput(_: *App, target: apprt.Target, value: apprt.action.SecureInput) void { - switch (target) { - .app => {}, - .surface => |surface| { - surface.rt_surface.setSecureInput(value); - }, - } -} - -fn closeWindow(_: *App, target: apprt.action.Target) !bool { - switch (target) { - .app => return false, - .surface => |v| { - const window = v.rt_surface.container.window() orelse return false; - window.closeWithConfirmation(); - return true; - }, - } -} - -fn quit(self: *App) void { - // If we're already not running, do nothing. - if (!self.running) return; - - // If the app says we don't need to confirm, then we can quit now. - if (!self.core_app.needsConfirmQuit()) { - self.quitNow(); - return; - } - - CloseDialog.show(.{ .app = self }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -/// This immediately destroys all windows, forcing the application to quit. -pub fn quitNow(self: *App) void { - const list = gtk.Window.listToplevels(); - defer list.free(); - list.foreach(struct { - fn callback(data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void { - const ptr = data orelse return; - const window: *gtk.Window = @ptrCast(@alignCast(ptr)); - - // We only want to destroy our windows. These windows own - // every other type of window that is possible so this will - // trigger a proper shutdown sequence. - // - // We previously just destroyed ALL windows but this leads to - // a double-free with the fcitx ime, because it has a nested - // gtk.Window as a property that we don't own and it later - // tries to free on its own. I think this is probably a bug in - // the fcitx ime widget but still, we don't want a double free! - // - // Since we don't use gobject directly we can't check class, - // so we use a heuristic based on CSS class. - if (window.as(gtk.Widget).hasCssClass("terminal-window") != 0) { - window.destroy(); - } - } - }.callback, null); - - self.running = false; -} - -// SIGUSR2 signal handler via g_unix_signal_add -fn sigusr2(ud: ?*anyopaque) callconv(.c) c_int { - const self: *App = @ptrCast(@alignCast(ud orelse - return @intFromBool(glib.SOURCE_CONTINUE))); - - log.info("received SIGUSR2, reloading configuration", .{}); - self.reloadConfig(.app, .{ .soft = false }) catch |err| { - log.err( - "error reloading configuration for SIGUSR2: {}", - .{err}, - ); - }; - - return @intFromBool(glib.SOURCE_CONTINUE); -} - -/// This is called by the `activate` signal. This is sent on program startup and -/// also when a secondary instance launches and requests a new window. -fn gtkActivate(_: *adw.Application, core_app: *CoreApp) callconv(.c) void { - // Queue a new window - _ = core_app.mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); -} - -fn gtkWindowAdded( - _: *adw.Application, - window: *gtk.Window, - core_app: *CoreApp, -) callconv(.c) void { - // Request the is-active property change so we can detect - // when our app loses focus. - _ = gobject.Object.signals.notify.connect( - window, - *CoreApp, - gtkWindowIsActive, - core_app, - .{ - .detail = "is-active", - }, - ); -} - -fn gtkWindowRemoved( - _: *adw.Application, - _: *gtk.Window, - core_app: *CoreApp, -) callconv(.c) void { - // Recheck if we are focused - gtkWindowIsActive(null, undefined, core_app); -} - -fn gtkWindowIsActive( - window: ?*gtk.Window, - _: *gobject.ParamSpec, - core_app: *CoreApp, -) callconv(.c) void { - // If our window is active, then we can tell the app - // that we are focused. - if (window) |w| { - if (w.isActive() != 0) { - core_app.focusEvent(true); - return; - } - } - - // If the window becomes inactive, we need to check if any - // other windows are active. If not, then we are no longer - // focused. - { - const list = gtk.Window.listToplevels(); - defer list.free(); - var current: ?*glib.List = list; - while (current) |elem| : (current = elem.f_next) { - // If the window is active then we are still focused. - // This is another window since we did our check above. - // That window should trigger its own is-active - // callback so we don't need to call it here. - const w: *gtk.Window = @alignCast(@ptrCast(elem.f_data)); - if (w.isActive() == 1) return; - } - } - - // We are not focused - core_app.focusEvent(false); -} - -fn adwNotifyDark( - style_manager: *adw.StyleManager, - _: *gobject.ParamSpec, - self: *App, -) callconv(.c) void { - const color_scheme: apprt.ColorScheme = if (style_manager.getDark() == 0) - .light - else - .dark; - - self.colorSchemeEvent(color_scheme); -} - -fn colorSchemeEvent( - self: *App, - scheme: apprt.ColorScheme, -) void { - self.core_app.colorSchemeEvent(self, scheme) catch |err| { - log.err("error updating app color scheme err={}", .{err}); - }; - - for (self.core_app.surfaces.items) |surface| { - surface.core_surface.colorSchemeCallback(scheme) catch |err| { - log.err("unable to tell surface about color scheme change err={}", .{err}); - }; - } -} - -fn gtkActionOpenConfig( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - _ = self.core_app.mailbox.push(.{ - .open_config = {}, - }, .{ .forever = {} }); -} - -fn gtkActionReloadConfig( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.reloadConfig(.app, .{}) catch |err| { - log.err("error reloading configuration: {}", .{err}); - }; -} - -fn gtkActionQuit( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.core_app.performAction(self, .quit) catch |err| { - log.err("error quitting err={}", .{err}); - }; -} - -/// Action sent by the window manager asking us to present a specific surface to -/// the user. Usually because the user clicked on a desktop notification. -fn gtkActionPresentSurface( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *App, -) callconv(.c) void { - const parameter = parameter_ orelse return; - - const t = glib.ext.VariantType.newFor(u64); - defer glib.VariantType.free(t); - - // Make sure that we've receiived a u64 from the system. - if (glib.Variant.isOfType(parameter, t) == 0) { - return; - } - - // Convert that u64 to pointer to a core surface. A value of zero - // means that there was no target surface for the notification so - // we don't focus any surface. - const ptr_int = parameter.getUint64(); - if (ptr_int == 0) return; - const surface: *CoreSurface = @ptrFromInt(ptr_int); - - // Send a message through the core app mailbox rather than presenting the - // surface directly so that it can validate that the surface pointer is - // valid. We could get an invalid pointer if a desktop notification outlives - // a Ghostty instance and a new one starts up, or there are multiple Ghostty - // instances running. - _ = self.core_app.mailbox.push( - .{ - .surface_message = .{ - .surface = surface, - .message = .{ .present_surface = {} }, - }, - }, - .{ .forever = {} }, - ); -} - -fn gtkActionShowGTKInspector( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.core_app.performAction(self, .show_gtk_inspector) catch |err| { - log.err("error showing GTK inspector err={}", .{err}); - }; -} - -fn gtkActionNewWindow( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *App, -) callconv(.c) void { - log.debug("received new window action", .{}); - - parameter: { - // were we given a parameter? - const parameter = parameter_ orelse break :parameter; - - const as = glib.VariantType.new("as"); - defer as.free(); - - // ensure that the supplied parameter is an array of strings - if (glib.Variant.isOfType(parameter, as) == 0) { - log.warn("parameter is of type {s}", .{parameter.getTypeString()}); - break :parameter; - } - - const s = glib.VariantType.new("s"); - defer s.free(); - - var it: glib.VariantIter = undefined; - _ = it.init(parameter); - - while (it.nextValue()) |value| { - defer value.unref(); - - // just to be sure - if (value.isOfType(s) == 0) continue; - - var len: usize = undefined; - const buf = value.getString(&len); - const str = buf[0..len]; - - log.debug("new-window command argument: {s}", .{str}); - } - } - - _ = self.core_app.mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); -} - -/// This is called to setup the action map that this application supports. -/// This should be called only once on startup. -fn initActions(self: *App) void { - // The set of actions. Each action has (in order): - // [0] The action name - // [1] The callback function - // [2] The GVariantType of the parameter - // - // For action names: - // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html - const t = glib.ext.VariantType.newFor(u64); - defer t.free(); - - const as = glib.VariantType.new("as"); - defer as.free(); - - const actions = .{ - .{ "quit", gtkActionQuit, null }, - .{ "open-config", gtkActionOpenConfig, null }, - .{ "reload-config", gtkActionReloadConfig, null }, - .{ "present-surface", gtkActionPresentSurface, t }, - .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, - .{ "new-window", gtkActionNewWindow, null }, - .{ "new-window-command", gtkActionNewWindow, as }, - }; - - inline for (actions) |entry| { - const action = gio.SimpleAction.new(entry[0], entry[2]); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *App, - entry[1], - self, - .{}, - ); - const action_map = self.app.as(gio.ActionMap); - action_map.addAction(action.as(gio.Action)); - } -} - -fn openConfig(self: *App) !bool { - // Get the config file path - const alloc = self.core_app.alloc; - const path = configpkg.edit.openPath(alloc) catch |err| { - log.warn("error getting config file path: {}", .{err}); - return false; - }; - defer alloc.free(path); - - // Open it using openURL. "path" isn't actually a URL but - // at the time of writing that works just fine for GTK. - self.openUrl(.{ .kind = .text, .url = path }); - return true; -} - -fn openUrl( - app: *App, - value: apprt.action.OpenUrl, -) void { - // TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html - - // Fallback to the minimal cross-platform way of opening a URL. - // This is always a safe fallback and enables for example Windows - // to open URLs (GTK on Windows via WSL is a thing). - internal_os.open( - app.core_app.alloc, - value.kind, - value.url, - ) catch |err| log.warn("unable to open url: {}", .{err}); +pub fn redrawInspector(_: *App, surface: *Surface) void { + surface.redrawInspector(); } diff --git a/src/apprt/gtk/Builder.zig b/src/apprt/gtk/Builder.zig deleted file mode 100644 index dbd765ba3..000000000 --- a/src/apprt/gtk/Builder.zig +++ /dev/null @@ -1,77 +0,0 @@ -/// Wrapper around GTK's builder APIs that perform some comptime checks. -const Builder = @This(); - -const std = @import("std"); - -const gtk = @import("gtk"); -const gobject = @import("gobject"); - -resource_name: [:0]const u8, -builder: ?*gtk.Builder, - -pub fn init( - /// The "name" of the resource. - comptime name: []const u8, - /// The major version of the minimum Adwaita version that is required to use - /// this resource. - comptime major: u16, - /// The minor version of the minimum Adwaita version that is required to use - /// this resource. - comptime minor: u16, -) Builder { - const resource_path = comptime resource_path: { - const gresource = @import("gresource.zig"); - // Check to make sure that our file is listed as a - // `blueprint_file` in `gresource.zig`. If it isn't Ghostty - // could crash at runtime when we try and load a nonexistent - // GResource. - for (gresource.blueprint_files) |file| { - if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue; - // Use @embedFile to make sure that the `.blp` file exists - // at compile time. Zig _should_ discard the data so that - // it doesn't end up in the final executable. At runtime we - // will load the data from a GResource. - const blp_filename = std.fmt.comptimePrint( - "ui/{d}.{d}/{s}.blp", - .{ - file.major, - file.minor, - file.name, - }, - ); - _ = @embedFile(blp_filename); - break :resource_path std.fmt.comptimePrint( - "/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui", - .{ - file.major, - file.minor, - file.name, - }, - ); - } else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig"); - }; - - return .{ - .resource_name = resource_path, - .builder = null, - }; -} - -pub fn setWidgetClassTemplate(self: *const Builder, class: *gtk.WidgetClass) void { - class.setTemplateFromResource(self.resource_name); -} - -pub fn getObject(self: *Builder, comptime T: type, name: [:0]const u8) ?*T { - const builder = builder: { - if (self.builder) |builder| break :builder builder; - const builder = gtk.Builder.newFromResource(self.resource_name); - self.builder = builder; - break :builder builder; - }; - - return gobject.ext.cast(T, builder.getObject(name) orelse return null); -} - -pub fn deinit(self: *const Builder) void { - if (self.builder) |builder| builder.unref(); -} diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig deleted file mode 100644 index a1d622143..000000000 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ /dev/null @@ -1,212 +0,0 @@ -/// Clipboard Confirmation Window -const ClipboardConfirmation = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gtk = @import("gtk"); -const adw = @import("adw"); -const gobject = @import("gobject"); -const gio = @import("gio"); - -const apprt = @import("../../apprt.zig"); -const CoreSurface = @import("../../Surface.zig"); -const App = @import("App.zig"); -const Builder = @import("Builder.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -app: *App, -dialog: *DialogType, -data: [:0]u8, -core_surface: *CoreSurface, -pending_req: apprt.ClipboardRequest, -text_view: *gtk.TextView, -text_view_scroll: *gtk.ScrolledWindow, -reveal_button: *gtk.Button, -hide_button: *gtk.Button, -remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque, - -pub fn create( - app: *App, - data: []const u8, - core_surface: *CoreSurface, - request: apprt.ClipboardRequest, - is_secure_input: bool, -) !void { - if (app.clipboard_confirmation_window != null) return error.WindowAlreadyExists; - - const alloc = app.core_app.alloc; - const self = try alloc.create(ClipboardConfirmation); - errdefer alloc.destroy(self); - - try self.init( - app, - data, - core_surface, - request, - is_secure_input, - ); - - app.clipboard_confirmation_window = self; -} - -/// Not public because this should be called by the GTK lifecycle. -fn destroy(self: *ClipboardConfirmation) void { - const alloc = self.app.core_app.alloc; - self.app.clipboard_confirmation_window = null; - alloc.free(self.data); - alloc.destroy(self); -} - -fn init( - self: *ClipboardConfirmation, - app: *App, - data: []const u8, - core_surface: *CoreSurface, - request: apprt.ClipboardRequest, - is_secure_input: bool, -) !void { - var builder: Builder = switch (DialogType) { - adw.AlertDialog => switch (request) { - .osc_52_read => .init("ccw-osc-52-read", 1, 5), - .osc_52_write => .init("ccw-osc-52-write", 1, 5), - .paste => .init("ccw-paste", 1, 5), - }, - adw.MessageDialog => switch (request) { - .osc_52_read => .init("ccw-osc-52-read", 1, 2), - .osc_52_write => .init("ccw-osc-52-write", 1, 2), - .paste => .init("ccw-paste", 1, 2), - }, - else => unreachable, - }; - defer builder.deinit(); - - const dialog = builder.getObject(DialogType, "clipboard_confirmation_window").?; - const text_view = builder.getObject(gtk.TextView, "text_view").?; - const reveal_button = builder.getObject(gtk.Button, "reveal_button").?; - const hide_button = builder.getObject(gtk.Button, "hide_button").?; - const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?; - const remember_choice = if (adw_version.supportsSwitchRow()) - builder.getObject(adw.SwitchRow, "remember_choice") - else - null; - - const copy = try app.core_app.alloc.dupeZ(u8, data); - errdefer app.core_app.alloc.free(copy); - self.* = .{ - .app = app, - .dialog = dialog, - .data = copy, - .core_surface = core_surface, - .pending_req = request, - .text_view = text_view, - .text_view_scroll = text_view_scroll, - .reveal_button = reveal_button, - .hide_button = hide_button, - .remember_choice = remember_choice, - }; - - const buffer = gtk.TextBuffer.new(null); - errdefer buffer.unref(); - buffer.insertAtCursor(copy.ptr, @intCast(copy.len)); - text_view.setBuffer(buffer); - - if (is_secure_input) { - text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); - self.text_view.as(gtk.Widget).addCssClass("blurred"); - - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); - - _ = gtk.Button.signals.clicked.connect( - reveal_button, - *ClipboardConfirmation, - gtkRevealButtonClicked, - self, - .{}, - ); - _ = gtk.Button.signals.clicked.connect( - hide_button, - *ClipboardConfirmation, - gtkHideButtonClicked, - self, - .{}, - ); - } - - _ = DialogType.signals.response.connect( - dialog, - *ClipboardConfirmation, - gtkResponse, - self, - .{}, - ); - - switch (DialogType) { - adw.AlertDialog => { - const parent: ?*gtk.Widget = widget: { - const window = core_surface.rt_surface.container.window() orelse break :widget null; - break :widget window.window.as(gtk.Widget); - }; - dialog.as(adw.Dialog).present(parent); - }, - adw.MessageDialog => dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { - const is_ok = std.mem.orderZ(u8, response, "ok") == .eq; - - if (is_ok) { - self.core_surface.completeClipboardRequest( - self.pending_req, - self.data, - true, - ) catch |err| { - log.err("Failed to requeue clipboard request: {}", .{err}); - }; - } - - if (self.remember_choice) |remember| remember: { - if (!adw_version.supportsSwitchRow()) break :remember; - if (remember.getActive() == 0) break :remember; - - switch (self.pending_req) { - .osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny, - .osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny, - .paste => {}, - } - } - - self.destroy(); -} -fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { - const dialog = gobject.ext.cast(DialogType, dialog_.?).?; - const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); - const response = dialog.chooseFinish(result); - self.handleResponse(response); -} - -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { - self.handleResponse(response); -} - -fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { - self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); - self.text_view.as(gtk.Widget).removeCssClass("blurred"); - - self.hide_button.as(gtk.Widget).setVisible(@intFromBool(true)); - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); -} - -fn gtkHideButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { - self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); - self.text_view.as(gtk.Widget).addCssClass("blurred"); - - self.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); -} diff --git a/src/apprt/gtk/CloseDialog.zig b/src/apprt/gtk/CloseDialog.zig deleted file mode 100644 index 559737cf4..000000000 --- a/src/apprt/gtk/CloseDialog.zig +++ /dev/null @@ -1,151 +0,0 @@ -const CloseDialog = @This(); -const std = @import("std"); - -const gobject = @import("gobject"); -const gio = @import("gio"); -const adw = @import("adw"); -const gtk = @import("gtk"); - -const i18n = @import("../../os/main.zig").i18n; -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Tab = @import("Tab.zig"); -const Surface = @import("Surface.zig"); -const adwaita = @import("adw_version.zig"); - -const log = std.log.scoped(.close_dialog); - -// We don't fall back to the GTK Message/AlertDialogs since -// we don't plan to support libadw < 1.2 as of time of writing -// TODO: Switch to just adw.AlertDialog when we drop Debian 12 support -const DialogType = if (adwaita.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -/// Open the dialog when the user requests to close a window/tab/split/etc. -/// but there's still one or more running processes inside the target that -/// cannot be closed automatically. We then ask the user whether they want -/// to terminate existing processes. -pub fn show(target: Target) !void { - // If we don't have a possible window to ask the user, - // in most situations (e.g. when a split isn't attached to a window) - // we should just close unconditionally. - const dialog_window = target.dialogWindow() orelse { - target.close(); - return; - }; - - const dialog = switch (DialogType) { - adw.AlertDialog => adw.AlertDialog.new(target.title(), target.body()), - adw.MessageDialog => adw.MessageDialog.new(dialog_window, target.title(), target.body()), - else => unreachable, - }; - - // AlertDialog and MessageDialog have essentially the same API, - // so we can cheat a little here - dialog.addResponse("cancel", i18n._("Cancel")); - dialog.setCloseResponse("cancel"); - - dialog.addResponse("close", i18n._("Close")); - dialog.setResponseAppearance("close", .destructive); - - // Need a stable pointer - const target_ptr = try target.allocator().create(Target); - target_ptr.* = target; - - _ = DialogType.signals.response.connect(dialog, *Target, responseCallback, target_ptr, .{}); - - switch (DialogType) { - adw.AlertDialog => dialog.as(adw.Dialog).present(dialog_window.as(gtk.Widget)), - adw.MessageDialog => dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn responseCallback( - _: *DialogType, - response: [*:0]const u8, - target: *Target, -) callconv(.c) void { - const alloc = target.allocator(); - defer alloc.destroy(target); - - if (std.mem.orderZ(u8, response, "close") == .eq) target.close(); -} - -/// The target of a close dialog. -/// -/// This is here so that we can consolidate all logic related to -/// prompting the user and closing windows/tabs/surfaces/etc. -/// together into one struct that is the sole source of truth. -pub const Target = union(enum) { - app: *App, - window: *Window, - tab: *Tab, - surface: *Surface, - - pub fn title(self: Target) [*:0]const u8 { - return switch (self) { - .app => i18n._("Quit Ghostty?"), - .window => i18n._("Close Window?"), - .tab => i18n._("Close Tab?"), - .surface => i18n._("Close Split?"), - }; - } - - pub fn body(self: Target) [*:0]const u8 { - return switch (self) { - .app => i18n._("All terminal sessions will be terminated."), - .window => i18n._("All terminal sessions in this window will be terminated."), - .tab => i18n._("All terminal sessions in this tab will be terminated."), - .surface => i18n._("The currently running process in this split will be terminated."), - }; - } - - pub fn dialogWindow(self: Target) ?*gtk.Window { - return switch (self) { - .app => { - // Find the currently focused window. We don't store this - // anywhere inside the App structure for some reason, so - // we have to query every single open window and see which - // one is active (focused and receiving keyboard input) - const list = gtk.Window.listToplevels(); - defer list.free(); - - const focused = list.findCustom(null, findActiveWindow); - return @ptrCast(@alignCast(focused.f_data)); - }, - .window => |v| v.window.as(gtk.Window), - .tab => |v| v.window.window.as(gtk.Window), - .surface => |v| { - const window_ = v.container.window() orelse return null; - return window_.window.as(gtk.Window); - }, - }; - } - - fn allocator(self: Target) std.mem.Allocator { - return switch (self) { - .app => |v| v.core_app.alloc, - .window => |v| v.app.core_app.alloc, - .tab => |v| v.window.app.core_app.alloc, - .surface => |v| v.app.core_app.alloc, - }; - } - - fn close(self: Target) void { - switch (self) { - .app => |v| v.quitNow(), - .window => |v| v.window.as(gtk.Window).destroy(), - .tab => |v| v.remove(), - .surface => |v| v.container.remove(), - } - } -}; - -fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int { - const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1))); - - // Confusingly, `isActive` returns 1 when active, - // but we want to return 0 to indicate equality. - // Abusing integers to be enums and booleans is a terrible idea, C. - return if (window.isActive() != 0) 0 else -1; -} diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig deleted file mode 100644 index 076459dbd..000000000 --- a/src/apprt/gtk/CommandPalette.zig +++ /dev/null @@ -1,258 +0,0 @@ -const CommandPalette = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const adw = @import("adw"); -const gio = @import("gio"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const configpkg = @import("../../config.zig"); -const inputpkg = @import("../../input.zig"); -const key = @import("key.zig"); -const Builder = @import("Builder.zig"); -const Window = @import("Window.zig"); - -const log = std.log.scoped(.command_palette); - -window: *Window, - -arena: std.heap.ArenaAllocator, - -/// The dialog object containing the palette UI. -dialog: *adw.Dialog, - -/// The search input text field. -search: *gtk.SearchEntry, - -/// The view containing each result row. -view: *gtk.ListView, - -/// The model that provides filtered data for the view to display. -model: *gtk.SingleSelection, - -/// The list that serves as the data source of the model. -/// This is where all command data is ultimately stored. -source: *gio.ListStore, - -pub fn init(self: *CommandPalette, window: *Window) !void { - // Register the custom command type *before* initializing the builder - // If we don't do this now, the builder will complain that it doesn't know - // about this type and fail to initialize - _ = Command.getGObjectType(); - - var builder = Builder.init("command-palette", 1, 5); - defer builder.deinit(); - - self.* = .{ - .window = window, - .arena = .init(window.app.core_app.alloc), - .dialog = builder.getObject(adw.Dialog, "command-palette").?, - .search = builder.getObject(gtk.SearchEntry, "search").?, - .view = builder.getObject(gtk.ListView, "view").?, - .model = builder.getObject(gtk.SingleSelection, "model").?, - .source = builder.getObject(gio.ListStore, "source").?, - }; - - // Manually take a reference here so that the dialog - // remains in memory after closing - self.dialog.ref(); - errdefer self.dialog.unref(); - - _ = gtk.SearchEntry.signals.stop_search.connect( - self.search, - *CommandPalette, - searchStopped, - self, - .{}, - ); - - _ = gtk.SearchEntry.signals.activate.connect( - self.search, - *CommandPalette, - searchActivated, - self, - .{}, - ); - - _ = gtk.ListView.signals.activate.connect( - self.view, - *CommandPalette, - rowActivated, - self, - .{}, - ); - - try self.updateConfig(&self.window.app.config); -} - -pub fn deinit(self: *CommandPalette) void { - self.arena.deinit(); - self.dialog.unref(); -} - -pub fn toggle(self: *CommandPalette) void { - self.dialog.present(self.window.window.as(gtk.Widget)); - // Focus on the search bar when opening the dialog - _ = self.search.as(gtk.Widget).grabFocus(); -} - -pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { - // Clear existing binds and clear allocated data - self.source.removeAll(); - _ = self.arena.reset(.retain_capacity); - - for (config.@"command-palette-entry".value.items) |command| { - // Filter out actions that are not implemented - // or don't make sense for GTK - switch (command.action) { - .close_all_windows, - .toggle_secure_input, - .check_for_updates, - .redo, - .undo, - .reset_window_size, - .toggle_window_float_on_top, - => continue, - - else => {}, - } - - const cmd = try Command.new( - self.arena.allocator(), - command, - config.keybind.set, - ); - const cmd_ref = cmd.as(gobject.Object); - self.source.append(cmd_ref); - cmd_ref.unref(); - } -} - -fn activated(self: *CommandPalette, pos: c_uint) void { - // Use self.model and not self.source here to use the list of *visible* results - const object = self.model.as(gio.ListModel).getObject(pos) orelse return; - const cmd = gobject.ext.cast(Command, object) orelse return; - - // Close before running the action in order to avoid being replaced by another - // dialog (such as the change title dialog). If that occurs then the command - // palette dialog won't be counted as having closed properly and cannot - // receive focus when reopened. - _ = self.dialog.close(); - - const action = inputpkg.Binding.Action.parse( - std.mem.span(cmd.cmd_c.action_key), - ) catch |err| { - log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err }); - return; - }; - - self.window.performBindingAction(action); -} - -fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { - // ESC was pressed - close the palette - _ = self.dialog.close(); -} - -fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { - // If Enter is pressed, activate the selected entry - self.activated(self.model.getSelected()); -} - -fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { - self.activated(pos); -} - -/// Object that wraps around a command. -/// -/// As GTK list models only accept objects that are within the GObject hierarchy, -/// we have to construct a wrapper to be easily consumed by the list model. -const Command = extern struct { - parent: Parent, - cmd_c: inputpkg.Command.C, - - pub const getGObjectType = gobject.ext.defineClass(Command, .{ - .name = "GhosttyCommand", - .classInit = Class.init, - }); - - pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command { - const self = gobject.ext.newInstance(Command, .{}); - var buf: [64]u8 = undefined; - - const action = action: { - const trigger = keybinds.getTrigger(cmd.action) orelse break :action null; - const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null; - break :action try alloc.dupeZ(u8, accel); - }; - - self.cmd_c = .{ - .title = cmd.title.ptr, - .description = cmd.description.ptr, - .action = if (action) |v| v.ptr else "", - .action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}), - }; - - return self; - } - - fn as(self: *Command, comptime T: type) *T { - return gobject.ext.as(T, self); - } - - pub const Parent = gobject.Object; - - pub const Class = extern struct { - parent: Parent.Class, - - pub const Instance = Command; - - pub fn init(class: *Class) callconv(.c) void { - const info = @typeInfo(inputpkg.Command.C).@"struct"; - - // Expose all fields on the Command.C struct as properties - // that can be accessed by the GObject type system - // (and by extension, blueprints) - const properties = comptime props: { - var props: [info.fields.len]type = undefined; - - for (info.fields, 0..) |field, i| { - const accessor = struct { - fn getter(cmd: *Command) ?[:0]const u8 { - return std.mem.span(@field(cmd.cmd_c, field.name)); - } - }; - - // "Canonicalize" field names into the format GObject expects - const prop_name = prop_name: { - var buf: [field.name.len:0]u8 = undefined; - _ = std.mem.replace(u8, field.name, "_", "-", &buf); - break :prop_name buf; - }; - - props[i] = gobject.ext.defineProperty( - &prop_name, - Command, - ?[:0]const u8, - .{ - .default = null, - .accessor = gobject.ext.typedAccessor( - Command, - ?[:0]const u8, - .{ - .getter = &accessor.getter, - }, - ), - }, - ); - } - - break :props props; - }; - - gobject.ext.registerProperties(class, &properties); - } - }; -}; diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig deleted file mode 100644 index da70ccce1..000000000 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ /dev/null @@ -1,102 +0,0 @@ -/// Configuration errors window. -const ConfigErrorsDialog = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gobject = @import("gobject"); -const gio = @import("gio"); -const gtk = @import("gtk"); -const adw = @import("adw"); - -const build_config = @import("../../build_config.zig"); -const configpkg = @import("../../config.zig"); -const Config = configpkg.Config; - -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Builder = @import("Builder.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -builder: Builder, -dialog: *DialogType, -error_message: *gtk.TextBuffer, - -pub fn maybePresent(app: *App, window: ?*Window) void { - if (app.config._diagnostics.empty()) return; - - const config_errors_dialog = config_errors_dialog: { - if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - - var builder: Builder = switch (DialogType) { - adw.AlertDialog => .init("config-errors-dialog", 1, 5), - adw.MessageDialog => .init("config-errors-dialog", 1, 2), - else => unreachable, - }; - - const dialog = builder.getObject(DialogType, "config_errors_dialog").?; - const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; - - _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); - - app.config_errors_dialog = .{ - .builder = builder, - .dialog = dialog, - .error_message = error_message, - }; - - break :config_errors_dialog app.config_errors_dialog.?; - }; - - { - var start = std.mem.zeroes(gtk.TextIter); - config_errors_dialog.error_message.getStartIter(&start); - - var end = std.mem.zeroes(gtk.TextIter); - config_errors_dialog.error_message.getEndIter(&end); - - config_errors_dialog.error_message.delete(&start, &end); - } - - var msg_buf: [4095:0]u8 = undefined; - var fbs = std.io.fixedBufferStream(&msg_buf); - - for (app.config._diagnostics.items()) |diag| { - fbs.reset(); - diag.write(fbs.writer()) catch |err| { - log.warn( - "error writing diagnostic to buffer err={}", - .{err}, - ); - continue; - }; - - config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); - config_errors_dialog.error_message.insertAtCursor("\n", 1); - } - - switch (DialogType) { - adw.AlertDialog => { - const parent = if (window) |w| w.window.as(gtk.Widget) else null; - config_errors_dialog.dialog.as(adw.Dialog).present(parent); - }, - adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void { - if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit(); - app.config_errors_dialog = null; - - if (std.mem.orderZ(u8, response, "reload") == .eq) { - app.reloadConfig(.app, .{}) catch |err| { - log.warn("error reloading config error={}", .{err}); - return; - }; - } -} diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig deleted file mode 100644 index 2506bef97..000000000 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ /dev/null @@ -1,422 +0,0 @@ -const GlobalShortcuts = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); - -const App = @import("App.zig"); -const configpkg = @import("../../config.zig"); -const Binding = @import("../../input.zig").Binding; -const key = @import("key.zig"); - -const log = std.log.scoped(.global_shortcuts); -const Token = [16]u8; - -app: *App, -arena: std.heap.ArenaAllocator, -dbus: *gio.DBusConnection, - -/// A mapping from a unique ID to an action. -/// Currently the unique ID is simply the serialized representation of the -/// trigger that was used for the action as triggers are unique in the keymap, -/// but this may change in the future. -map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, - -/// The handle of the current global shortcuts portal session, -/// as a D-Bus object path. -handle: ?[:0]const u8 = null, - -/// The D-Bus signal subscription for the response signal on requests. -/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. -response_subscription: c_uint = 0, - -/// The D-Bus signal subscription for the keybind activate signal. -/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. -activate_subscription: c_uint = 0, - -pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts { - const dbus = gio_app.getDbusConnection() orelse return null; - - return .{ - // To be initialized later - .app = undefined, - .arena = .init(alloc), - .dbus = dbus, - }; -} - -pub fn deinit(self: *GlobalShortcuts) void { - self.close(); - self.arena.deinit(); -} - -fn close(self: *GlobalShortcuts) void { - if (self.response_subscription != 0) { - self.dbus.signalUnsubscribe(self.response_subscription); - self.response_subscription = 0; - } - - if (self.activate_subscription != 0) { - self.dbus.signalUnsubscribe(self.activate_subscription); - self.activate_subscription = 0; - } - - if (self.handle) |handle| { - // Close existing session - self.dbus.call( - "org.freedesktop.portal.Desktop", - handle, - "org.freedesktop.portal.Session", - "Close", - null, - null, - .{}, - -1, - null, - null, - null, - ); - self.handle = null; - } -} - -pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { - // Ensure we have a valid reference to the app - // (it was left uninitialized in `init`) - self.app = app; - - // Close any existing sessions - self.close(); - - // Update map - var trigger_buf: [256]u8 = undefined; - - self.map.clearRetainingCapacity(); - var it = self.app.config.keybind.set.bindings.iterator(); - - while (it.next()) |entry| { - const leaf = switch (entry.value_ptr.*) { - // Global shortcuts can't have leaders - .leader => continue, - .leaf => |leaf| leaf, - }; - if (!leaf.flags.global) continue; - - const trigger = try key.xdgShortcutFromTrigger( - &trigger_buf, - entry.key_ptr.*, - ) orelse continue; - - try self.map.put( - self.arena.allocator(), - try self.arena.allocator().dupeZ(u8, trigger), - leaf.action, - ); - } - - if (self.map.count() > 0) { - try self.request(.create_session); - } -} - -fn shortcutActivated( - _: *gio.DBusConnection, - _: ?[*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - params: *glib.Variant, - ud: ?*anyopaque, -) callconv(.c) void { - const self: *GlobalShortcuts = @ptrCast(@alignCast(ud)); - - // 2nd value in the tuple is the activated shortcut ID - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated - var shortcut_id: [*:0]const u8 = undefined; - params.getChild(1, "&s", &shortcut_id); - log.debug("activated={s}", .{shortcut_id}); - - const action = self.map.get(std.mem.span(shortcut_id)) orelse return; - - self.app.core_app.performAllAction(self.app, action) catch |err| { - log.err("failed to perform action={}", .{err}); - }; -} - -const Method = enum { - create_session, - bind_shortcuts, - - fn name(self: Method) [:0]const u8 { - return switch (self) { - .create_session => "CreateSession", - .bind_shortcuts => "BindShortcuts", - }; - } - - /// Construct the payload expected by the XDG portal call. - fn makePayload( - self: Method, - shortcuts: *GlobalShortcuts, - request_token: [:0]const u8, - ) ?*glib.Variant { - switch (self) { - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession - .create_session => { - var session_token: Token = undefined; - return glib.Variant.newParsed( - "({'handle_token': <%s>, 'session_handle_token': <%s>},)", - request_token.ptr, - generateToken(&session_token).ptr, - ); - }, - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts - .bind_shortcuts => { - const handle = shortcuts.handle orelse return null; - - const bind_type = glib.VariantType.new("a(sa{sv})"); - defer glib.free(bind_type); - - var binds: glib.VariantBuilder = undefined; - glib.VariantBuilder.init(&binds, bind_type); - - var action_buf: [256]u8 = undefined; - - var it = shortcuts.map.iterator(); - while (it.next()) |entry| { - const trigger = entry.key_ptr.*.ptr; - const action = std.fmt.bufPrintZ( - &action_buf, - "{}", - .{entry.value_ptr.*}, - ) catch continue; - - binds.addParsed( - "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", - trigger, - action.ptr, - trigger, - ); - } - - return glib.Variant.newParsed( - "(%o, %*, '', {'handle_token': <%s>})", - handle.ptr, - binds.end(), - request_token.ptr, - ); - }, - } - } - - fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { - switch (self) { - .create_session => { - var handle: ?[*:0]u8 = null; - if (vardict.lookup("session_handle", "&s", &handle) == 0) { - log.err( - "session handle not found in response={s}", - .{vardict.print(@intFromBool(true))}, - ); - return; - } - - shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch { - log.err("out of memory: failed to clone session handle", .{}); - return; - }; - - log.debug("session_handle={?s}", .{handle}); - - // Subscribe to keybind activations - shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe( - null, - "org.freedesktop.portal.GlobalShortcuts", - "Activated", - "/org/freedesktop/portal/desktop", - handle, - .{ .match_arg0_path = true }, - shortcutActivated, - shortcuts, - null, - ); - - shortcuts.request(.bind_shortcuts) catch |err| { - log.err("failed to bind shortcuts={}", .{err}); - return; - }; - }, - .bind_shortcuts => {}, - } - } -}; - -/// Submit a request to the global shortcuts portal. -fn request( - self: *GlobalShortcuts, - comptime method: Method, -) !void { - // NOTE(pluiedev): - // XDG Portals are really, really poorly-designed pieces of hot garbage. - // How the protocol is _initially_ designed to work is as follows: - // - // 1. The client calls a method which returns the path of a Request object; - // 2. The client waits for the Response signal under said object path; - // 3. When the signal arrives, the actual return value and status code - // become available for the client for further processing. - // - // THIS DOES NOT WORK. Once the first two steps are complete, the client - // needs to immediately start listening for the third step, but an overeager - // server implementation could easily send the Response signal before the - // client is even ready, causing communications to break down over a simple - // race condition/two generals' problem that even _TCP_ had figured out - // decades ago. Worse yet, you get exactly _one_ chance to listen for the - // signal, or else your communication attempt so far has all been in vain. - // - // And they know this. Instead of fixing their freaking protocol, they just - // ask clients to manually construct the expected object path and subscribe - // to the request signal beforehand, making the whole response value of - // the original call COMPLETELY MEANINGLESS. - // - // Furthermore, this is _entirely undocumented_ aside from one tiny - // paragraph under the documentation for the Request interface, and - // anyone would be forgiven for missing it without reading the libportal - // source code. - // - // When in Rome, do as the Romans do, I guess...? - - const callbacks = struct { - fn gotResponseHandle( - source: ?*gobject.Object, - res: *gio.AsyncResult, - _: ?*anyopaque, - ) callconv(.c) void { - const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; - - var err: ?*glib.Error = null; - defer if (err) |err_| err_.free(); - - const params_ = dbus_.callFinish(res, &err) orelse { - if (err) |err_| log.err("request failed={s} ({})", .{ - err_.f_message orelse "(unknown)", - err_.f_code, - }); - return; - }; - defer params_.unref(); - - // TODO: XDG recommends updating the signal subscription if the actual - // returned request path is not the same as the expected request - // path, to retain compatibility with older versions of XDG portals. - // Although it suffers from the race condition outlined above, - // we should still implement this at some point. - } - - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response - fn responded( - dbus: *gio.DBusConnection, - _: ?[*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - params_: *glib.Variant, - ud: ?*anyopaque, - ) callconv(.c) void { - const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud)); - - // Unsubscribe from the response signal - if (self_.response_subscription != 0) { - dbus.signalUnsubscribe(self_.response_subscription); - self_.response_subscription = 0; - } - - var response: u32 = 0; - var vardict: ?*glib.Variant = null; - defer if (vardict) |v| v.unref(); - params_.get("(u@a{sv})", &response, &vardict); - - switch (response) { - 0 => { - log.debug("request successful", .{}); - method.onResponse(self_, vardict.?); - }, - 1 => log.debug("request was cancelled by user", .{}), - 2 => log.warn("request ended unexpectedly", .{}), - else => log.err("unrecognized response code={}", .{response}), - } - } - }; - - var request_token_buf: Token = undefined; - const request_token = generateToken(&request_token_buf); - - const payload = method.makePayload(self, request_token) orelse return; - const request_path = try self.getRequestPath(request_token); - - self.response_subscription = self.dbus.signalSubscribe( - null, - "org.freedesktop.portal.Request", - "Response", - request_path, - null, - .{}, - callbacks.responded, - self, - null, - ); - - self.dbus.call( - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.GlobalShortcuts", - method.name(), - payload, - null, - .{}, - -1, - null, - callbacks.gotResponseHandle, - null, - ); -} - -/// Generate a random token suitable for use in requests. -fn generateToken(buf: *Token) [:0]const u8 { - // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL - // 7 + 8 + 1 = 16 - return std.fmt.bufPrintZ( - buf, - "ghostty_{x:0<7}", - .{std.crypto.random.int(u28)}, - ) catch unreachable; -} - -/// Get the XDG portal request path for the current Ghostty instance. -/// -/// If this sounds like nonsense, see `request` for an explanation as to -/// why we need to do this. -fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 { - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html - // for the syntax XDG portals expect. - - // `getUniqueName` should never return null here as we're using an ordinary - // message bus connection. If it doesn't, something is very wrong - const unique_name = std.mem.span(self.dbus.getUniqueName().?); - - const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{ - "/org/freedesktop/portal/desktop/request", - unique_name[1..], // Remove leading `:` - token, - }); - - // Sanitize the unique name by replacing every `.` with `_`. - // In effect, this will turn a unique name like `:1.192` into `1_192`. - // Valid D-Bus object path components never contain `.`s anyway, so we're - // free to replace all instances of `.` here and avoid extra allocation. - std.mem.replaceScalar(u8, object_path, '.', '_'); - - return object_path; -} diff --git a/src/apprt/gtk/ImguiWidget.zig b/src/apprt/gtk/ImguiWidget.zig deleted file mode 100644 index 338fd7982..000000000 --- a/src/apprt/gtk/ImguiWidget.zig +++ /dev/null @@ -1,470 +0,0 @@ -const ImguiWidget = @This(); - -const std = @import("std"); -const assert = std.debug.assert; - -const gdk = @import("gdk"); -const gtk = @import("gtk"); -const cimgui = @import("cimgui"); -const gl = @import("opengl"); - -const key = @import("key.zig"); -const input = @import("../../input.zig"); - -const log = std.log.scoped(.gtk_imgui_widget); - -/// This is called every frame to populate the ImGui frame. -render_callback: ?*const fn (?*anyopaque) void = null, -render_userdata: ?*anyopaque = null, - -/// Our OpenGL widget -gl_area: *gtk.GLArea, -im_context: *gtk.IMContext, - -/// ImGui Context -ig_ctx: *cimgui.c.ImGuiContext, - -/// Our previous instant used to calculate delta time for animations. -instant: ?std.time.Instant = null, - -/// Initialize the widget. This must have a stable pointer for events. -pub fn init(self: *ImguiWidget) !void { - // Each widget gets its own imgui context so we can have multiple - // imgui views in the same application. - const ig_ctx = cimgui.c.igCreateContext(null) orelse return error.OutOfMemory; - errdefer cimgui.c.igDestroyContext(ig_ctx); - cimgui.c.igSetCurrentContext(ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - io.BackendPlatformName = "ghostty_gtk"; - - // Our OpenGL area for drawing - const gl_area = gtk.GLArea.new(); - gl_area.setAutoRender(@intFromBool(true)); - - // The GL area has to be focusable so that it can receive events - gl_area.as(gtk.Widget).setFocusable(@intFromBool(true)); - gl_area.as(gtk.Widget).setFocusOnClick(@intFromBool(true)); - - // Clicks - const gesture_click = gtk.GestureClick.new(); - errdefer gesture_click.unref(); - gesture_click.as(gtk.GestureSingle).setButton(0); - gl_area.as(gtk.Widget).addController(gesture_click.as(gtk.EventController)); - - // Mouse movement - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - gl_area.as(gtk.Widget).addController(ec_motion.as(gtk.EventController)); - - // Scroll events - const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes); - errdefer ec_scroll.unref(); - gl_area.as(gtk.Widget).addController(ec_scroll.as(gtk.EventController)); - - // Focus controller will tell us about focus enter/exit events - const ec_focus = gtk.EventControllerFocus.new(); - errdefer ec_focus.unref(); - gl_area.as(gtk.Widget).addController(ec_focus.as(gtk.EventController)); - - // Key event controller will tell us about raw keypress events. - const ec_key = gtk.EventControllerKey.new(); - errdefer ec_key.unref(); - gl_area.as(gtk.Widget).addController(ec_key.as(gtk.EventController)); - errdefer gl_area.as(gtk.Widget).removeController(ec_key.as(gtk.EventController)); - - // The input method context that we use to translate key events into - // characters. This doesn't have an event key controller attached because - // we call it manually from our own key controller. - const im_context = gtk.IMMulticontext.new(); - errdefer im_context.unref(); - - // Signals - _ = gtk.Widget.signals.realize.connect( - gl_area, - *ImguiWidget, - gtkRealize, - self, - .{}, - ); - _ = gtk.Widget.signals.unrealize.connect( - gl_area, - *ImguiWidget, - gtkUnrealize, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - gl_area, - *ImguiWidget, - gtkDestroy, - self, - .{}, - ); - _ = gtk.GLArea.signals.render.connect( - gl_area, - *ImguiWidget, - gtkRender, - self, - .{}, - ); - _ = gtk.GLArea.signals.resize.connect( - gl_area, - *ImguiWidget, - gtkResize, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key, - *ImguiWidget, - gtkKeyPressed, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_released.connect( - ec_key, - *ImguiWidget, - gtkKeyReleased, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.enter.connect( - ec_focus, - *ImguiWidget, - gtkFocusEnter, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.leave.connect( - ec_focus, - *ImguiWidget, - gtkFocusLeave, - self, - .{}, - ); - _ = gtk.GestureClick.signals.pressed.connect( - gesture_click, - *ImguiWidget, - gtkMouseDown, - self, - .{}, - ); - _ = gtk.GestureClick.signals.released.connect( - gesture_click, - *ImguiWidget, - gtkMouseUp, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.motion.connect( - ec_motion, - *ImguiWidget, - gtkMouseMotion, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll.connect( - ec_scroll, - *ImguiWidget, - gtkMouseScroll, - self, - .{}, - ); - _ = gtk.IMContext.signals.commit.connect( - im_context, - *ImguiWidget, - gtkInputCommit, - self, - .{}, - ); - - self.* = .{ - .gl_area = gl_area, - .im_context = im_context.as(gtk.IMContext), - .ig_ctx = ig_ctx, - }; -} - -/// Deinitialize the widget. This should ONLY be called if the widget gl_area -/// was never added to a parent. Otherwise, cleanup automatically happens -/// when the widget is destroyed and this should NOT be called. -pub fn deinit(self: *ImguiWidget) void { - cimgui.c.igDestroyContext(self.ig_ctx); -} - -/// This should be called anytime the underlying data for the UI changes -/// so that the UI can be refreshed. -pub fn queueRender(self: *const ImguiWidget) void { - self.gl_area.queueRender(); -} - -/// Initialize the frame. Expects that the context is already current. -fn newFrame(self: *ImguiWidget) !void { - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - // Determine our delta time - const now = try std.time.Instant.now(); - io.DeltaTime = if (self.instant) |prev| delta: { - const since_ns = now.since(prev); - const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s); - break :delta @max(0.00001, since_s); - } else (1 / 60); - self.instant = now; -} - -fn translateMouseButton(button: c_uint) ?c_int { - return switch (button) { - 1 => cimgui.c.ImGuiMouseButton_Left, - 2 => cimgui.c.ImGuiMouseButton_Middle, - 3 => cimgui.c.ImGuiMouseButton_Right, - else => null, - }; -} - -fn gtkDestroy(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - log.debug("imgui widget destroy", .{}); - self.deinit(); -} - -fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - log.debug("gl surface realized", .{}); - - // We need to make the context current so we can call GL functions. - area.makeCurrent(); - if (area.getError()) |err| { - log.err("surface failed to realize: {s}", .{err.f_message orelse "(unknown)"}); - return; - } - - // realize means that our OpenGL context is ready, so we can now - // initialize the ImgUI OpenGL backend for our context. - cimgui.c.igSetCurrentContext(self.ig_ctx); - _ = cimgui.ImGui_ImplOpenGL3_Init(null); -} - -fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - _ = area; - log.debug("gl surface unrealized", .{}); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - cimgui.ImGui_ImplOpenGL3_Shutdown(); -} - -fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget) callconv(.c) void { - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const scale_factor = area.as(gtk.Widget).getScaleFactor(); - log.debug("gl resize width={} height={} scale={}", .{ - width, - height, - scale_factor, - }); - - // Our display size is always unscaled. We'll do the scaling in the - // style instead. This creates crisper looking fonts. - io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; - io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; - - // Setup a new style and scale it appropriately. - const style = cimgui.c.ImGuiStyle_ImGuiStyle(); - defer cimgui.c.ImGuiStyle_destroy(style); - cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor)); - const active_style = cimgui.c.igGetStyle(); - active_style.* = style.*; -} - -fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *ImguiWidget) callconv(.c) c_int { - cimgui.c.igSetCurrentContext(self.ig_ctx); - - // Setup our frame. We render twice because some ImGui behaviors - // take multiple renders to process. I don't know how to make this - // more efficient. - for (0..2) |_| { - cimgui.ImGui_ImplOpenGL3_NewFrame(); - self.newFrame() catch |err| { - log.err("failed to setup frame: {}", .{err}); - return 0; - }; - cimgui.c.igNewFrame(); - - // Build our UI - if (self.render_callback) |cb| cb(self.render_userdata); - - // Render - cimgui.c.igRender(); - } - - // OpenGL final render - gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); - gl.clear(gl.c.GL_COLOR_BUFFER_BIT); - cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData()); - - return 1; -} - -fn gtkMouseMotion( - _: *gtk.EventControllerMotion, - x: f64, - y: f64, - self: *ImguiWidget, -) callconv(.c) void { - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const scale_factor: f64 = @floatFromInt(self.gl_area.as(gtk.Widget).getScaleFactor()); - cimgui.c.ImGuiIO_AddMousePosEvent( - io, - @floatCast(x * scale_factor), - @floatCast(y * scale_factor), - ); - self.queueRender(); -} - -fn gtkMouseDown( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); - if (translateMouseButton(gdk_button)) |button| { - cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); - } -} - -fn gtkMouseUp( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); - if (translateMouseButton(gdk_button)) |button| { - cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); - } -} - -fn gtkMouseScroll( - _: *gtk.EventControllerScroll, - x: f64, - y: f64, - self: *ImguiWidget, -) callconv(.c) c_int { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddMouseWheelEvent( - io, - @floatCast(x), - @floatCast(-y), - ); - - return @intFromBool(true); -} - -fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddFocusEvent(io, true); -} - -fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddFocusEvent(io, false); -} - -fn gtkInputCommit( - _: *gtk.IMMulticontext, - bytes: [*:0]u8, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *ImguiWidget, -) callconv(.c) c_int { - return @intFromBool(self.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )); -} - -fn gtkKeyReleased( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *ImguiWidget, -) callconv(.c) void { - _ = self.keyEvent( - .release, - ec_key, - keyval, - keycode, - gtk_mods, - ); -} - -fn keyEvent( - self: *ImguiWidget, - action: input.Action, - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, -) bool { - _ = keycode; - - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - const mods = key.translateMods(gtk_mods); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); - - // If our keyval has a key, then we send that key event - if (key.keyFromKeyval(keyval)) |inputkey| { - if (inputkey.imguiKey()) |imgui_key| { - cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press); - } - } - - // Try to process the event as text - if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| { - _ = self.im_context.filterKeypress(event); - } - - return true; -} diff --git a/src/apprt/gtk/ProgressBar.zig b/src/apprt/gtk/ProgressBar.zig deleted file mode 100644 index 1518e84c2..000000000 --- a/src/apprt/gtk/ProgressBar.zig +++ /dev/null @@ -1,165 +0,0 @@ -//! Structure for managing GUI progress bar for a surface. -const ProgressBar = @This(); - -const std = @import("std"); - -const glib = @import("glib"); -const gtk = @import("gtk"); - -const Surface = @import("./Surface.zig"); -const terminal = @import("../../terminal/main.zig"); - -const log = std.log.scoped(.gtk_progress_bar); - -/// The surface that we belong to. -surface: *Surface, - -/// Widget for showing progress bar. -progress_bar: ?*gtk.ProgressBar = null, - -/// Timer used to remove the progress bar if we have not received an update from -/// the TUI in a while. -progress_bar_timer: ?c_uint = null, - -pub fn init(surface: *Surface) ProgressBar { - return .{ - .surface = surface, - }; -} - -pub fn deinit(self: *ProgressBar) void { - self.stopProgressBarTimer(); -} - -/// Show (or update if it already exists) a GUI progress bar. -pub fn handleProgressReport(self: *ProgressBar, value: terminal.osc.Command.ProgressReport) error{}!bool { - // Remove the progress bar. - if (value.state == .remove) { - self.stopProgressBarTimer(); - self.removeProgressBar(); - - return true; - } - - const progress_bar = self.addProgressBar(); - self.startProgressBarTimer(); - - switch (value.state) { - // already handled above - .remove => unreachable, - - // Set the progress bar to a fixed value if one was provided, otherwise pulse. - // Remove the `error` CSS class so that the progress bar shows as normal. - .set => { - progress_bar.as(gtk.Widget).removeCssClass("error"); - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } else { - progress_bar.pulse(); - } - }, - - // Set the progress bar to a fixed value if one was provided, otherwise pulse. - // Set the `error` CSS class so that the progress bar shows as an error color. - .@"error" => { - progress_bar.as(gtk.Widget).addCssClass("error"); - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } else { - progress_bar.pulse(); - } - }, - - // The state of progress is unknown, so pulse the progress bar to - // indicate that things are still happening. - .indeterminate => { - progress_bar.pulse(); - }, - - // If a progress value was provided, set the progress bar to that value. - // Don't pulse the progress bar as that would indicate that things were - // happening. Otherwise this is mainly used to keep the progress bar on - // screen instead of timing out. - .pause => { - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } - }, - } - - return true; -} - -/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped -/// to [0, 100]. -fn computeFraction(progress: u8) f64 { - return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0; -} - -test "computeFraction" { - try std.testing.expectEqual(1.0, computeFraction(100)); - try std.testing.expectEqual(1.0, computeFraction(255)); - try std.testing.expectEqual(0.0, computeFraction(0)); - try std.testing.expectEqual(0.5, computeFraction(50)); -} - -/// Add a progress bar to our overlay. -fn addProgressBar(self: *ProgressBar) *gtk.ProgressBar { - if (self.progress_bar) |progress_bar| return progress_bar; - - const progress_bar = gtk.ProgressBar.new(); - self.progress_bar = progress_bar; - - const progress_bar_widget = progress_bar.as(gtk.Widget); - progress_bar_widget.setHalign(.fill); - progress_bar_widget.setValign(.start); - progress_bar_widget.addCssClass("osd"); - - self.surface.overlay.addOverlay(progress_bar_widget); - - return progress_bar; -} - -/// Remove the progress bar from our overlay. -fn removeProgressBar(self: *ProgressBar) void { - if (self.progress_bar) |progress_bar| { - const progress_bar_widget = progress_bar.as(gtk.Widget); - self.surface.overlay.removeOverlay(progress_bar_widget); - self.progress_bar = null; - } -} - -/// Start a timer that will remove the progress bar if the TUI forgets to remove -/// it. -fn startProgressBarTimer(self: *ProgressBar) void { - const progress_bar_timeout_seconds = 15; - - // Remove an old timer that hasn't fired yet. - self.stopProgressBarTimer(); - - self.progress_bar_timer = glib.timeoutAdd( - progress_bar_timeout_seconds * std.time.ms_per_s, - handleProgressBarTimeout, - self, - ); -} - -/// Stop any existing timer for removing the progress bar. -fn stopProgressBarTimer(self: *ProgressBar) void { - if (self.progress_bar_timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove progress bar timer", .{}); - } - self.progress_bar_timer = null; - } -} - -/// The progress bar hasn't been updated by the TUI recently, remove it. -fn handleProgressBarTimeout(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ProgressBar = @ptrCast(@alignCast(ud.?)); - - self.progress_bar_timer = null; - self.removeProgressBar(); - - return @intFromBool(glib.SOURCE_REMOVE); -} diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig deleted file mode 100644 index 2ab59624a..000000000 --- a/src/apprt/gtk/ResizeOverlay.zig +++ /dev/null @@ -1,206 +0,0 @@ -const ResizeOverlay = @This(); - -const std = @import("std"); - -const glib = @import("glib"); -const gtk = @import("gtk"); - -const configpkg = @import("../../config.zig"); -const Surface = @import("Surface.zig"); - -const log = std.log.scoped(.gtk); - -/// local copy of configuration data -const DerivedConfig = struct { - resize_overlay: configpkg.Config.ResizeOverlay, - resize_overlay_position: configpkg.Config.ResizeOverlayPosition, - resize_overlay_duration: configpkg.Config.Duration, - - pub fn init(config: *const configpkg.Config) DerivedConfig { - return .{ - .resize_overlay = config.@"resize-overlay", - .resize_overlay_position = config.@"resize-overlay-position", - .resize_overlay_duration = config.@"resize-overlay-duration", - }; - } -}; - -/// the surface that we are attached to -surface: *Surface, - -/// a copy of the configuration that we need to operate -config: DerivedConfig, - -/// If non-null this is the widget on the overlay that shows the size of the -/// surface when it is resized. -label: ?*gtk.Label = null, - -/// If non-null this is a timer for dismissing the resize overlay. -timer: ?c_uint = null, - -/// If non-null this is a timer for dismissing the resize overlay. -idler: ?c_uint = null, - -/// If true, the next resize event will be the first one. -first: bool = true, - -/// Initialize the ResizeOverlay. This doesn't do anything more than save a -/// pointer to the surface that we are a part of as all of the widget creation -/// is done later. -pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { - self.* = .{ - .surface = surface, - .config = .init(config), - }; -} - -pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { - self.config = .init(config); -} - -/// De-initialize the ResizeOverlay. This removes any pending idlers/timers that -/// may not have fired yet. -pub fn deinit(self: *ResizeOverlay) void { - if (self.idler) |idler| { - if (glib.Source.remove(idler) == 0) { - log.warn("unable to remove resize overlay idler", .{}); - } - self.idler = null; - } - - if (self.timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove resize overlay timer", .{}); - } - self.timer = null; - } -} - -/// If we're configured to do so, update the text in the resize overlay widget -/// and make it visible. Schedule a timer to hide the widget after the delay -/// expires. -/// -/// If we're not configured to show the overlay, do nothing. -pub fn maybeShow(self: *ResizeOverlay) void { - switch (self.config.resize_overlay) { - .never => return, - .always => {}, - .@"after-first" => if (self.first) { - self.first = false; - return; - }, - } - - self.first = false; - - // When updating a widget, wait until GTK is "idle", i.e. not in the middle - // of doing any other updates. Since we are called in the middle of resizing - // GTK is doing a lot of work rearranging all of the widgets. Not doing this - // results in a lot of warnings from GTK and _horrible_ flickering of the - // resize overlay. - if (self.idler != null) return; - self.idler = glib.idleAdd(gtkUpdate, self); -} - -/// Actually update the overlay widget. This should only be called from a GTK -/// idle handler. -fn gtkUpdate(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); - - // No matter what our idler is complete with this callback - self.idler = null; - - const grid_size = self.surface.core_surface.size.grid(); - var buf: [32]u8 = undefined; - const text = std.fmt.bufPrintZ( - &buf, - "{d} x {d}", - .{ - grid_size.columns, - grid_size.rows, - }, - ) catch |err| { - log.err("unable to format text: {}", .{err}); - return 0; - }; - - if (self.label) |label| { - // The resize overlay widget already exists, just update it. - label.setText(text.ptr); - setPosition(label, &self.config); - show(label); - } else { - // Create the resize overlay widget. - const label = gtk.Label.new(text.ptr); - label.setJustify(gtk.Justification.center); - label.setSelectable(0); - setPosition(label, &self.config); - - const widget = label.as(gtk.Widget); - widget.addCssClass("view"); - widget.addCssClass("size-overlay"); - widget.setFocusable(0); - widget.setCanTarget(0); - - const overlay: *gtk.Overlay = @ptrCast(@alignCast(self.surface.overlay)); - overlay.addOverlay(widget); - - self.label = label; - } - - if (self.timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove size overlay timer", .{}); - } - } - - self.timer = glib.timeoutAdd( - self.surface.app.config.@"resize-overlay-duration".asMilliseconds(), - gtkTimerExpired, - self, - ); - - return 0; -} - -// This should only be called from a GTK idle handler or timer. -fn show(label: *gtk.Label) void { - const widget = label.as(gtk.Widget); - widget.removeCssClass("hidden"); -} - -// This should only be called from a GTK idle handler or timer. -fn hide(label: *gtk.Label) void { - const widget = label.as(gtk.Widget); - widget.addCssClass("hidden"); -} - -/// Update the position of the resize overlay widget. It might seem excessive to -/// do this often, but it should make hot config reloading of the position work. -/// This should only be called from a GTK idle handler. -fn setPosition(label: *gtk.Label, config: *DerivedConfig) void { - const widget = label.as(gtk.Widget); - widget.setHalign( - switch (config.resize_overlay_position) { - .center, .@"top-center", .@"bottom-center" => gtk.Align.center, - .@"top-left", .@"bottom-left" => gtk.Align.start, - .@"top-right", .@"bottom-right" => gtk.Align.end, - }, - ); - widget.setValign( - switch (config.resize_overlay_position) { - .center => gtk.Align.center, - .@"top-left", .@"top-center", .@"top-right" => gtk.Align.start, - .@"bottom-left", .@"bottom-center", .@"bottom-right" => gtk.Align.end, - }, - ); -} - -/// If this fires, it means that the delay period has expired and the resize -/// overlay widget should be hidden. -fn gtkTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); - self.timer = null; - if (self.label) |label| hide(label); - return 0; -} diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig deleted file mode 100644 index fb719c3c9..000000000 --- a/src/apprt/gtk/Split.zig +++ /dev/null @@ -1,441 +0,0 @@ -/// Split represents a surface split where two surfaces are shown side-by-side -/// within the same window either vertically or horizontally. -const Split = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const apprt = @import("../../apprt.zig"); -const font = @import("../../font/main.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const Surface = @import("Surface.zig"); -const Tab = @import("Tab.zig"); - -const log = std.log.scoped(.gtk); - -/// The split orientation. -pub const Orientation = enum { - horizontal, - vertical, - - pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation { - return switch (direction) { - .right, .left => .horizontal, - .down, .up => .vertical, - }; - } - - pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation { - return switch (direction) { - .up, .down => .vertical, - .left, .right => .horizontal, - }; - } -}; - -/// Our actual GtkPaned widget -paned: *gtk.Paned, - -/// The container for this split panel. -container: Surface.Container, - -/// The orientation of this split panel. -orientation: Orientation, - -/// The elements of this split panel. -top_left: Surface.Container.Elem, -bottom_right: Surface.Container.Elem, - -/// Create a new split panel with the given sibling surface in the given -/// direction. The direction is where the new surface will be initialized. -/// -/// The sibling surface can be in a split already or it can be within a -/// tab. This properly handles updating the surface container so that -/// it represents the new split. -pub fn create( - alloc: Allocator, - sibling: *Surface, - direction: apprt.action.SplitDirection, -) !*Split { - var split = try alloc.create(Split); - errdefer alloc.destroy(split); - try split.init(sibling, direction); - return split; -} - -pub fn init( - self: *Split, - sibling: *Surface, - direction: apprt.action.SplitDirection, -) !void { - // If our sibling is too small to be split in half then we don't - // allow the split to happen. This avoids a situation where the - // split becomes too small. - // - // This is kind of a hack. Ideally we'd use gtk_widget_set_size_request - // properly along the path to ensure minimum sizes. I don't know if - // GTK even respects that all but any way GTK does this for us seems - // better than this. - { - // This is the min size of the sibling split. This means the - // smallest split is half of this. - const multiplier = 4; - - const size = &sibling.core_surface.size; - const small = switch (direction) { - .right, .left => size.screen.width < size.cell.width * multiplier, - .down, .up => size.screen.height < size.cell.height * multiplier, - }; - if (small) return error.SplitTooSmall; - } - - // Create the new child surface for the other direction. - const alloc = sibling.app.core_app.alloc; - var surface = try Surface.create(alloc, sibling.app, .{ - .parent = &sibling.core_surface, - }); - errdefer surface.destroy(alloc); - sibling.dimSurface(); - sibling.setSplitZoom(false); - - // Create the actual GTKPaned, attach the proper children. - const orientation: gtk.Orientation = switch (direction) { - .right, .left => .horizontal, - .down, .up => .vertical, - }; - const paned = gtk.Paned.new(orientation); - errdefer paned.unref(); - - // Keep a long-lived reference, which we unref in destroy. - paned.ref(); - - // Update all of our containers to point to the right place. - // The split has to point to where the sibling pointed to because - // we're inheriting its parent. The sibling points to its location - // in the split, and the surface points to the other location. - const container = sibling.container; - const tl: *Surface, const br: *Surface = switch (direction) { - .right, .down => right_down: { - sibling.container = .{ .split_tl = &self.top_left }; - surface.container = .{ .split_br = &self.bottom_right }; - break :right_down .{ sibling, surface }; - }, - - .left, .up => left_up: { - sibling.container = .{ .split_br = &self.bottom_right }; - surface.container = .{ .split_tl = &self.top_left }; - break :left_up .{ surface, sibling }; - }, - }; - - self.* = .{ - .paned = paned, - .container = container, - .top_left = .{ .surface = tl }, - .bottom_right = .{ .surface = br }, - .orientation = .fromDirection(direction), - }; - - // Replace the previous containers element with our split. This allows a - // non-split to become a split, a split to become a nested split, etc. - container.replace(.{ .split = self }); - - // Update our children so that our GL area is properly added to the paned. - self.updateChildren(); - - // The new surface should always grab focus - surface.grabFocus(); -} - -pub fn destroy(self: *Split, alloc: Allocator) void { - self.top_left.deinit(alloc); - self.bottom_right.deinit(alloc); - - // Clean up our GTK reference. This will trigger all the destroy callbacks - // that are necessary for the surfaces to clean up. - self.paned.unref(); - - alloc.destroy(self); -} - -/// Remove the top left child. -pub fn removeTopLeft(self: *Split) void { - self.removeChild(self.top_left, self.bottom_right); -} - -/// Remove the top left child. -pub fn removeBottomRight(self: *Split) void { - self.removeChild(self.bottom_right, self.top_left); -} - -fn removeChild( - self: *Split, - remove: Surface.Container.Elem, - keep: Surface.Container.Elem, -) void { - const window = self.container.window() orelse return; - const alloc = window.app.core_app.alloc; - - // Remove our children since we are going to no longer be a split anyways. - // This prevents widgets with multiple parents. - self.removeChildren(); - - // Our container must become whatever our top left is - self.container.replace(keep); - - // Grab focus of the left-over side - keep.grabFocus(); - - // When a child is removed we are no longer a split, so destroy ourself - remove.deinit(alloc); - alloc.destroy(self); -} - -/// Move the divider in the given direction by the given amount. -pub fn moveDivider( - self: *Split, - direction: apprt.action.ResizeSplit.Direction, - amount: u16, -) void { - const min_pos = 10; - - const pos = self.paned.getPosition(); - const new = switch (direction) { - .up, .left => @max(pos - amount, min_pos), - .down, .right => new_pos: { - const max_pos: u16 = @as(u16, @intFromFloat(self.maxPosition())) - min_pos; - break :new_pos @min(pos + amount, max_pos); - }, - }; - - self.paned.setPosition(new); -} - -/// Equalize the splits in this split panel. Each split is equalized based on -/// its weight, i.e. the number of Surfaces it contains. -/// -/// It works recursively by equalizing the children of each split. -/// -/// It returns this split's weight. -pub fn equalize(self: *Split) f64 { - // Calculate weights of top_left/bottom_right - const top_left_weight = self.top_left.equalize(); - const bottom_right_weight = self.bottom_right.equalize(); - const weight = top_left_weight + bottom_right_weight; - - // Ratio of top_left weight to overall weight, which gives the split ratio - const ratio = top_left_weight / weight; - - // Convert split ratio into new position for divider - self.paned.setPosition(@intFromFloat(self.maxPosition() * ratio)); - - return weight; -} - -// maxPosition returns the maximum position of the GtkPaned, which is the -// "max-position" attribute. -fn maxPosition(self: *Split) f64 { - var value: gobject.Value = std.mem.zeroes(gobject.Value); - defer value.unset(); - - _ = value.init(gobject.ext.types.int); - self.paned.as(gobject.Object).getProperty( - "max-position", - &value, - ); - - return @floatFromInt(value.getInt()); -} - -// This replaces the element at the given pointer with a new element. -// The ptr must be either top_left or bottom_right (asserted in debug). -// The memory of the old element must be freed or otherwise handled by -// the caller. -pub fn replace( - self: *Split, - ptr: *Surface.Container.Elem, - new: Surface.Container.Elem, -) void { - // We can write our element directly. There's nothing special. - assert(&self.top_left == ptr or &self.bottom_right == ptr); - ptr.* = new; - - // Update our paned children. This will reset the divider - // position but we want to keep it in place so save and restore it. - const pos = self.paned.getPosition(); - defer self.paned.setPosition(pos); - self.updateChildren(); -} - -// grabFocus grabs the focus of the top-left element. -pub fn grabFocus(self: *Split) void { - self.top_left.grabFocus(); -} - -/// Update the paned children to represent the current state. -/// This should be called anytime the top/left or bottom/right -/// element is changed. -pub fn updateChildren(self: *const Split) void { - // We have to set both to null. If we overwrite the pane with - // the same value, then GTK bugs out (the GL area unrealizes - // and never rerealizes). - self.removeChildren(); - - // Set our current children - self.paned.setStartChild(self.top_left.widget()); - self.paned.setEndChild(self.bottom_right.widget()); -} - -/// A mapping of direction to the element (if any) in that direction. -pub const DirectionMap = std.EnumMap( - apprt.action.GotoSplit, - ?*Surface, -); - -pub const Side = enum { top_left, bottom_right }; - -/// Returns the map that can be used to determine elements in various -/// directions (primarily for gotoSplit). -pub fn directionMap(self: *const Split, from: Side) DirectionMap { - var result = DirectionMap.initFull(null); - - if (self.directionPrevious(from)) |prev| { - result.put(.previous, prev.surface); - if (!prev.wrapped) { - result.put(.up, prev.surface); - } - } - - if (self.directionNext(from)) |next| { - result.put(.next, next.surface); - if (!next.wrapped) { - result.put(.down, next.surface); - } - } - - if (self.directionLeft(from)) |left| { - result.put(.left, left); - } - - if (self.directionRight(from)) |right| { - result.put(.right, right); - } - - return result; -} - -fn directionLeft(self: *const Split, from: Side) ?*Surface { - switch (from) { - .bottom_right => { - switch (self.orientation) { - .horizontal => return self.top_left.deepestSurface(.bottom_right), - .vertical => return directionLeft( - self.container.split() orelse return null, - .bottom_right, - ), - } - }, - .top_left => return directionLeft( - self.container.split() orelse return null, - .bottom_right, - ), - } -} - -fn directionRight(self: *const Split, from: Side) ?*Surface { - switch (from) { - .top_left => { - switch (self.orientation) { - .horizontal => return self.bottom_right.deepestSurface(.top_left), - .vertical => return directionRight( - self.container.split() orelse return null, - .top_left, - ), - } - }, - .bottom_right => return directionRight( - self.container.split() orelse return null, - .top_left, - ), - } -} - -fn directionPrevious(self: *const Split, from: Side) ?struct { - surface: *Surface, - wrapped: bool, -} { - switch (from) { - // From the bottom right, our previous is the deepest surface - // in the top-left of our own split. - .bottom_right => return .{ - .surface = self.top_left.deepestSurface(.bottom_right) orelse return null, - .wrapped = false, - }, - - // From the top left its more complicated. It is the de - .top_left => { - // If we have no parent split then there can be no unwrapped prev. - // We can still have a wrapped previous. - const parent = self.container.split() orelse return .{ - .surface = self.bottom_right.deepestSurface(.bottom_right) orelse return null, - .wrapped = true, - }; - - // The previous value is the previous of the side that we are. - const side = self.container.splitSide() orelse return null; - return switch (side) { - .top_left => parent.directionPrevious(.top_left), - .bottom_right => parent.directionPrevious(.bottom_right), - }; - }, - } -} - -fn directionNext(self: *const Split, from: Side) ?struct { - surface: *Surface, - wrapped: bool, -} { - switch (from) { - // From the top left, our next is the earliest surface in the - // top-left direction of the bottom-right side of our split. Fun! - .top_left => return .{ - .surface = self.bottom_right.deepestSurface(.top_left) orelse return null, - .wrapped = false, - }, - - // From the bottom right is more compliated. It is the deepest - // (last) surface in the - .bottom_right => { - // If we have no parent split then there can be no next. - const parent = self.container.split() orelse return .{ - .surface = self.top_left.deepestSurface(.top_left) orelse return null, - .wrapped = true, - }; - - // The previous value is the previous of the side that we are. - const side = self.container.splitSide() orelse return null; - return switch (side) { - .top_left => parent.directionNext(.top_left), - .bottom_right => parent.directionNext(.bottom_right), - }; - }, - } -} - -pub fn detachTopLeft(self: *const Split) void { - self.paned.setStartChild(null); -} - -pub fn detachBottomRight(self: *const Split) void { - self.paned.setEndChild(null); -} - -fn removeChildren(self: *const Split) void { - self.detachTopLeft(); - self.detachBottomRight(); -} diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 231ab0c09..ac82f941b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1,1222 +1,63 @@ -/// A surface represents one drawable terminal surface. The surface may be -/// attached to a window or it may be some other kind of surface. This struct -/// is meant to be generic to all scenarios. -const Surface = @This(); +const Self = @This(); const std = @import("std"); - -const adw = @import("adw"); -const gtk = @import("gtk"); -const gdk = @import("gdk"); -const glib = @import("glib"); -const gio = @import("gio"); -const gobject = @import("gobject"); - -const Allocator = std.mem.Allocator; -const build_config = @import("../../build_config.zig"); -const build_options = @import("build_options"); -const configpkg = @import("../../config.zig"); const apprt = @import("../../apprt.zig"); -const font = @import("../../font/main.zig"); -const i18n = @import("../../os/main.zig").i18n; -const input = @import("../../input.zig"); -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); const CoreSurface = @import("../../Surface.zig"); -const internal_os = @import("../../os/main.zig"); +const ApprtApp = @import("App.zig"); +const Application = @import("class/application.zig").Application; +const Surface = @import("class/surface.zig").Surface; -const App = @import("App.zig"); -const Split = @import("Split.zig"); -const Tab = @import("Tab.zig"); -const Window = @import("Window.zig"); -const Menu = @import("menu.zig").Menu; -const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); -const ResizeOverlay = @import("ResizeOverlay.zig"); -const URLWidget = @import("URLWidget.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const inspectorpkg = @import("inspector.zig"); -const gtk_key = @import("key.zig"); -const Builder = @import("Builder.zig"); -const ProgressBar = @import("ProgressBar.zig"); -const adw_version = @import("adw_version.zig"); +/// The GObject Surface +surface: *Surface, -const log = std.log.scoped(.gtk_surface); - -pub const Options = struct { - /// The parent surface to inherit settings such as font size, working - /// directory, etc. from. - parent: ?*CoreSurface = null, -}; - -/// The container that this surface is directly attached to. -pub const Container = union(enum) { - /// The surface is not currently attached to anything. This means - /// that the GLArea has been created and potentially initialized - /// but the widget is currently floating and not part of any parent. - none: void, - - /// Directly attached to a tab. (i.e. no splits) - tab_: *Tab, - - /// A split within a split hierarchy. The key determines the - /// position of the split within the parent split. - split_tl: *Elem, - split_br: *Elem, - - /// The side of the split. - pub const SplitSide = enum { top_left, bottom_right }; - - /// Elem is the possible element of any container. A container can - /// hold both a surface and a split. Any valid container should - /// have an Elem value so that it can be properly used with - /// splits. - pub const Elem = union(enum) { - /// A surface is a leaf element of the split -- a terminal - /// surface. - surface: *Surface, - - /// A split is a nested split within a split. This lets you - /// for example have a horizontal split with a vertical split - /// on the left side (amongst all other possible - /// combinations). - split: *Split, - - /// Returns the GTK widget to add to the paned for the given - /// element - pub fn widget(self: Elem) *gtk.Widget { - return switch (self) { - .surface => |s| s.primaryWidget(), - .split => |s| s.paned.as(gtk.Widget), - }; - } - - pub fn containerPtr(self: Elem) *Container { - return switch (self) { - .surface => |s| &s.container, - .split => |s| &s.container, - }; - } - - pub fn deinit(self: Elem, alloc: Allocator) void { - switch (self) { - .surface => |s| s.unref(), - .split => |s| s.destroy(alloc), - } - } - - pub fn grabFocus(self: Elem) void { - switch (self) { - .surface => |s| s.grabFocus(), - .split => |s| s.grabFocus(), - } - } - - pub fn equalize(self: Elem) f64 { - return switch (self) { - .surface => 1, - .split => |s| s.equalize(), - }; - } - - /// The last surface in this container in the direction specified. - /// Direction must be "top_left" or "bottom_right". - pub fn deepestSurface(self: Elem, side: SplitSide) ?*Surface { - return switch (self) { - .surface => |s| s, - .split => |s| (switch (side) { - .top_left => s.top_left, - .bottom_right => s.bottom_right, - }).deepestSurface(side), - }; - } - }; - - /// Returns the window that this surface is attached to. - pub fn window(self: Container) ?*Window { - return switch (self) { - .none => null, - .tab_ => |v| v.window, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - break :split s.container.window(); - }, - }; - } - - /// Returns the tab container if it exists. - pub fn tab(self: Container) ?*Tab { - return switch (self) { - .none => null, - .tab_ => |v| v, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - break :split s.container.tab(); - }, - }; - } - - /// Returns the split containing this surface (if any). - pub fn split(self: Container) ?*Split { - return switch (self) { - .none, .tab_ => null, - .split_tl => |ptr| @fieldParentPtr("top_left", ptr), - .split_br => |ptr| @fieldParentPtr("bottom_right", ptr), - }; - } - - /// The side that we are in the split. - pub fn splitSide(self: Container) ?SplitSide { - return switch (self) { - .none, .tab_ => null, - .split_tl => .top_left, - .split_br => .bottom_right, - }; - } - - /// Returns the first split with the given orientation, walking upwards in - /// the tree. - pub fn firstSplitWithOrientation( - self: Container, - orientation: Split.Orientation, - ) ?*Split { - return switch (self) { - .none, .tab_ => null, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - if (s.orientation == orientation) break :split s; - break :split s.container.firstSplitWithOrientation(orientation); - }, - }; - } - - /// Replace the container's element with this element. This is - /// used by children to modify their parents to for example change - /// from a surface to a split or a split back to a surface or - /// a split to a nested split and so on. - pub fn replace(self: Container, elem: Elem) void { - // Move the element into the container - switch (self) { - .none => {}, - .tab_ => |t| t.replaceElem(elem), - inline .split_tl, .split_br => |ptr| { - const s = self.split().?; - s.replace(ptr, elem); - }, - } - - // Update the reverse reference to the container - elem.containerPtr().* = self; - } - - /// Remove ourselves from the container. This is used by - /// children to effectively notify they're container that - /// all children at this level are exiting. - pub fn remove(self: Container) void { - switch (self) { - .none => {}, - .tab_ => |t| t.remove(), - .split_tl => self.split().?.removeTopLeft(), - .split_br => self.split().?.removeBottomRight(), - } - } -}; - -/// Whether the surface has been realized or not yet. When a surface is -/// "realized" it means that the OpenGL context is ready and the core -/// surface has been initialized. -realized: bool = false, - -/// The config to use to initialize a surface. -init_config: InitConfig, - -/// The GUI container that this surface has been attached to. This -/// dictates some behaviors such as new splits, etc. -container: Container = .{ .none = {} }, - -/// The app we're part of -app: *App, - -/// The overlay, this is the primary widget -overlay: *gtk.Overlay, - -/// Our GTK area -gl_area: *gtk.GLArea, - -/// If non-null this is the widget on the overlay that shows the URL. -url_widget: ?URLWidget = null, - -/// The overlay that shows resizing information. -resize_overlay: ResizeOverlay = undefined, - -/// Whether or not the current surface is zoomed in (see `toggle_split_zoom`). -zoomed_in: bool = false, - -/// If non-null this is the widget on the overlay which dims the surface when it is unfocused -unfocused_widget: ?*gtk.Widget = null, - -/// Any active cursor we may have -cursor: ?*gdk.Cursor = null, - -/// Our title. The raw value of the title. This will be kept up to date and -/// .title will be updated if we have focus. -/// When set the text in this buf will be null-terminated, because we need to -/// pass it to GTK. -title_text: ?[:0]const u8 = null, - -/// The title of the surface as reported by the terminal. If it is null, the -/// title reported by the terminal is currently being used. If the title was -/// manually overridden by the user, this will be set to a non-null value -/// representing the default terminal title. -title_from_terminal: ?[:0]const u8 = null, - -/// Our current working directory. We use this value for setting tooltips in -/// the headerbar subtitle if we have focus. When set, the text in this buf -/// will be null-terminated because we need to pass it to GTK. -pwd: ?[:0]const u8 = null, - -/// The timer used to delay title updates in order to prevent flickering. -update_title_timer: ?c_uint = null, - -/// The core surface backing this surface -core_surface: CoreSurface, - -/// The font size to use for this surface once realized. -font_size: ?font.face.DesiredSize = null, - -/// Cached metrics about the surface from GTK callbacks. -size: apprt.SurfaceSize, -cursor_pos: apprt.CursorPos, - -/// Inspector state. -inspector: ?*inspectorpkg.Inspector = null, - -/// Key input states. See gtkKeyPressed for detailed descriptions. -in_keyevent: IMKeyEvent = .false, -im_context: *gtk.IMMulticontext, -im_composing: bool = false, -im_buf: [128]u8 = undefined, -im_len: u7 = 0, - -/// The surface-specific cgroup path. See App.transient_cgroup_path for -/// details on what this is. -cgroup_path: ?[]const u8 = null, - -/// Our context menu. -context_menu: Menu(Surface, "context_menu", false), - -/// True when we have a precision scroll in progress -precision_scroll: bool = false, - -/// Flag indicating whether the surface is in secure input mode. -is_secure_input: bool = false, - -/// Structure for managing GUI progress bar -progress_bar: ProgressBar, - -/// The state of the key event while we're doing IM composition. -/// See gtkKeyPressed for detailed descriptions. -pub const IMKeyEvent = enum { - /// Not in a key event. - false, - - /// In a key event but im_composing was either true or false - /// prior to the calling IME processing. This is important to - /// work around different input methods calling commit and - /// preedit end in a different order. - composing, - not_composing, -}; - -/// Configuration used for initializing the surface. We have to copy some -/// data since initialization is delayed with GTK (on realize). -pub const InitConfig = struct { - parent: bool = false, - pwd: ?[]const u8 = null, - - pub fn init( - alloc: Allocator, - app: *App, - opts: Options, - ) Allocator.Error!InitConfig { - const parent = opts.parent orelse return .{}; - - const pwd: ?[]const u8 = if (app.config.@"window-inherit-working-directory") - try parent.pwd(alloc) - else - null; - errdefer if (pwd) |p| alloc.free(p); - - return .{ - .parent = true, - .pwd = pwd, - }; - } - - pub fn deinit(self: *InitConfig, alloc: Allocator) void { - if (self.pwd) |pwd| alloc.free(pwd); - } -}; - -pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface { - var surface = try alloc.create(Surface); - errdefer alloc.destroy(surface); - try surface.init(app, opts); - return surface; +pub fn deinit(self: *Self) void { + _ = self; } -pub fn init(self: *Surface, app: *App, opts: Options) !void { - const gl_area = gtk.GLArea.new(); - const gl_area_widget = gl_area.as(gtk.Widget); - - // Create an overlay so we can layer the GL area with other widgets. - const overlay = gtk.Overlay.new(); - errdefer overlay.unref(); - const overlay_widget = overlay.as(gtk.Widget); - overlay.setChild(gl_area_widget); - - // Overlay is not focusable, but the GL area is. - overlay_widget.setFocusable(0); - overlay_widget.setFocusOnClick(0); - - // We grab the floating reference to the primary widget. This allows the - // widget tree to be moved around i.e. between a split, a tab, etc. - // without having to be really careful about ordering to - // prevent a destroy. - // - // This is unref'd in the unref() method that's called by the - // self.container through Elem.deinit. - _ = overlay.as(gobject.Object).refSink(); - errdefer overlay.unref(); - - // We want the gl area to expand to fill the parent container. - gl_area_widget.setHexpand(1); - gl_area_widget.setVexpand(1); - - // Various other GL properties - gl_area_widget.setCursorFromName("text"); - gl_area.setRequiredVersion( - renderer.OpenGL.MIN_VERSION_MAJOR, - renderer.OpenGL.MIN_VERSION_MINOR, - ); - gl_area.setHasStencilBuffer(0); - gl_area.setHasDepthBuffer(0); - gl_area.setUseEs(0); - - // Key event controller will tell us about raw keypress events. - const ec_key = gtk.EventControllerKey.new(); - errdefer ec_key.unref(); - overlay_widget.addController(ec_key.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_key.as(gtk.EventController)); - - // Focus controller will tell us about focus enter/exit events - const ec_focus = gtk.EventControllerFocus.new(); - errdefer ec_focus.unref(); - overlay_widget.addController(ec_focus.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_focus.as(gtk.EventController)); - - // Create a second key controller so we can receive the raw - // key-press events BEFORE the input method gets them. - const ec_key_press = gtk.EventControllerKey.new(); - errdefer ec_key_press.unref(); - overlay_widget.addController(ec_key_press.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_key_press.as(gtk.EventController)); - - // Clicks - const gesture_click = gtk.GestureClick.new(); - errdefer gesture_click.unref(); - gesture_click.as(gtk.GestureSingle).setButton(0); - overlay_widget.addController(gesture_click.as(gtk.EventController)); - errdefer overlay_widget.removeController(gesture_click.as(gtk.EventController)); - - // Mouse movement - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - overlay_widget.addController(ec_motion.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_motion.as(gtk.EventController)); - - // Scroll events - const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes); - errdefer ec_scroll.unref(); - overlay_widget.addController(ec_scroll.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_scroll.as(gtk.EventController)); - - // The input method context that we use to translate key events into - // characters. This doesn't have an event key controller attached because - // we call it manually from our own key controller. - const im_context = gtk.IMMulticontext.new(); - errdefer im_context.unref(); - - // The GL area has to be focusable so that it can receive events - gl_area_widget.setFocusable(1); - gl_area_widget.setFocusOnClick(1); - - // Set up to handle items being dropped on our surface. Files can be dropped - // from Nautilus and strings can be dropped from many programs. - const drop_target = gtk.DropTarget.new(gobject.ext.types.invalid, .flags_copy); - errdefer drop_target.unref(); - // The order of the types matters. - var drop_target_types = [_]gobject.Type{ - gdk.FileList.getGObjectType(), - gio.File.getGObjectType(), - gobject.ext.types.string, - }; - drop_target.setGtypes(&drop_target_types, drop_target_types.len); - overlay_widget.addController(drop_target.as(gtk.EventController)); - errdefer overlay_widget.removeController(drop_target.as(gtk.EventController)); - - // Inherit the parent's font size if we have a parent. - const font_size: ?font.face.DesiredSize = font_size: { - if (!app.config.@"window-inherit-font-size") break :font_size null; - const parent = opts.parent orelse break :font_size null; - break :font_size parent.font_size; - }; - - // If the parent has a transient cgroup, then we're creating cgroups - // for each surface if we can. We need to create a child cgroup. - const cgroup_path: ?[]const u8 = cgroup: { - const base = app.transient_cgroup_base orelse break :cgroup null; - - // For the unique group name we use the self pointer. This may - // not be a good idea for security reasons but not sure yet. We - // may want to change this to something else eventually to be safe. - var buf: [256]u8 = undefined; - const name = std.fmt.bufPrint( - &buf, - "surfaces/{X}.scope", - .{@intFromPtr(self)}, - ) catch unreachable; - - // Create the cgroup. If it fails, no big deal... just ignore. - internal_os.cgroup.create(base, name, null) catch |err| { - log.err("failed to create surface cgroup err={}", .{err}); - break :cgroup null; - }; - - // Success, save the cgroup path. - break :cgroup std.fmt.allocPrint( - app.core_app.alloc, - "{s}/{s}", - .{ base, name }, - ) catch null; - }; - errdefer if (cgroup_path) |path| app.core_app.alloc.free(path); - - // Build our initialization config - const init_config = try InitConfig.init(app.core_app.alloc, app, opts); - errdefer init_config.deinit(app.core_app.alloc); - - // Build our result - self.* = .{ - .app = app, - .container = .{ .none = {} }, - .overlay = overlay, - .gl_area = gl_area, - .resize_overlay = undefined, - .title_text = null, - .core_surface = undefined, - .font_size = font_size, - .init_config = init_config, - .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = -1, .y = -1 }, - .im_context = im_context, - .cgroup_path = cgroup_path, - .context_menu = undefined, - .progress_bar = .init(self), - }; - errdefer self.* = undefined; - - // initialize the context menu - self.context_menu.init(self); - self.context_menu.setParent(overlay.as(gtk.Widget)); - - // initialize the resize overlay - self.resize_overlay.init(self, &app.config); - - // Set our default mouse shape - try self.setMouseShape(.text); - - // GL events - _ = gtk.Widget.signals.realize.connect( - gl_area, - *Surface, - gtkRealize, - self, - .{}, - ); - _ = gtk.Widget.signals.unrealize.connect( - gl_area, - *Surface, - gtkUnrealize, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - gl_area, - *Surface, - gtkDestroy, - self, - .{}, - ); - _ = gtk.GLArea.signals.render.connect( - gl_area, - *Surface, - gtkRender, - self, - .{}, - ); - _ = gtk.GLArea.signals.resize.connect( - gl_area, - *Surface, - gtkResize, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key_press, - *Surface, - gtkKeyPressed, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_released.connect( - ec_key_press, - *Surface, - gtkKeyReleased, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.enter.connect( - ec_focus, - *Surface, - gtkFocusEnter, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.leave.connect( - ec_focus, - *Surface, - gtkFocusLeave, - self, - .{}, - ); - _ = gtk.GestureClick.signals.pressed.connect( - gesture_click, - *Surface, - gtkMouseDown, - self, - .{}, - ); - _ = gtk.GestureClick.signals.released.connect( - gesture_click, - *Surface, - gtkMouseUp, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.motion.connect( - ec_motion, - *Surface, - gtkMouseMotion, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.leave.connect( - ec_motion, - *Surface, - gtkMouseLeave, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll.connect( - ec_scroll, - *Surface, - gtkMouseScroll, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll_begin.connect( - ec_scroll, - *Surface, - gtkMouseScrollPrecisionBegin, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll_end.connect( - ec_scroll, - *Surface, - gtkMouseScrollPrecisionEnd, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_start.connect( - im_context, - *Surface, - gtkInputPreeditStart, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_changed.connect( - im_context, - *Surface, - gtkInputPreeditChanged, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_end.connect( - im_context, - *Surface, - gtkInputPreeditEnd, - self, - .{}, - ); - _ = gtk.IMContext.signals.commit.connect( - im_context, - *Surface, - gtkInputCommit, - self, - .{}, - ); - _ = gtk.DropTarget.signals.drop.connect( - drop_target, - *Surface, - gtkDrop, - self, - .{}, - ); +/// Returns the GObject surface for this apprt surface. This is a function +/// so we can add some extra logic if we ever have to here. +pub fn gobj(self: *Self) *Surface { + return self.surface; } -fn realize(self: *Surface) !void { - // If this surface has already been realized, then we don't need to - // reinitialize. This can happen if a surface is moved from one GDK - // surface to another (i.e. a tab is pulled out into a window). - if (self.realized) { - // If we have no OpenGL state though, we do need to reinitialize. - // We allow the renderer to figure that out, and then queue a draw. - try self.core_surface.renderer.displayRealized(); - self.redraw(); - return; - } - - // Add ourselves to the list of surfaces on the app. - try self.app.core_app.addSurface(self); - errdefer self.app.core_app.deleteSurface(self); - - // Get our new surface config - var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config); - defer config.deinit(); - - if (self.init_config.pwd) |pwd| { - // If we have a working directory we want, then we force that. - config.@"working-directory" = pwd; - } else if (!self.init_config.parent) { - // A hack, see the "parent_surface" field for more information. - config.@"working-directory" = self.app.config.@"working-directory"; - } - - // Initialize our surface now that we have the stable pointer. - try self.core_surface.init( - self.app.core_app.alloc, - &config, - self.app.core_app, - self.app, - self, - ); - errdefer self.core_surface.deinit(); - - // If we have a font size we want, set that now - if (self.font_size) |size| { - try self.core_surface.setFontSize(size); - } - - // Note we're realized - self.realized = true; +pub fn core(self: *Self) *CoreSurface { + // This asserts the non-optional because libghostty should only + // be calling this for initialized surfaces. + return self.surface.core().?; } -pub fn deinit(self: *Surface) void { - self.init_config.deinit(self.app.core_app.alloc); - if (self.title_text) |title| self.app.core_app.alloc.free(title); - if (self.title_from_terminal) |title| self.app.core_app.alloc.free(title); - if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd); - - // We don't allocate anything if we aren't realized. - if (!self.realized) return; - - // Cleanup the progress bar. - self.progress_bar.deinit(); - - // Delete our inspector if we have one - self.controlInspector(.hide); - - // Remove ourselves from the list of known surfaces in the app. - self.app.core_app.deleteSurface(self); - - // Clean up our core surface so that all the rendering and IO stop. - self.core_surface.deinit(); - self.core_surface = undefined; - - // Remove the cgroup if we have one. We do this after deiniting the core - // surface to ensure all processes have exited. - if (self.cgroup_path) |path| { - internal_os.cgroup.remove(path) catch |err| { - // We don't want this to be fatal in any way so we just log - // and continue. A dangling empty cgroup is not a big deal - // and this should be rare. - log.warn( - "failed to remove cgroup for surface path={s} err={}", - .{ path, err }, - ); - }; - - self.app.core_app.alloc.free(path); - } - - // Free all our GTK stuff - // - // Note we don't do anything with the "unfocused_overlay" because - // it is attached to the overlay which by this point has been destroyed - // and therefore the unfocused_overlay has been destroyed as well. - self.im_context.unref(); - if (self.cursor) |cursor| cursor.unref(); - if (self.update_title_timer) |timer| _ = glib.Source.remove(timer); - self.resize_overlay.deinit(); +pub fn rtApp(self: *Self) *ApprtApp { + _ = self; + return Application.default().rt(); } -pub fn core(self: *Surface) *CoreSurface { - return &self.core_surface; +pub fn close(self: *Self, process_active: bool) void { + _ = process_active; + self.surface.close(); } -pub fn rtApp(self: *const Surface) *App { - return self.app; +pub fn cgroup(self: *Self) ?[]const u8 { + return self.surface.cgroupPath(); } -/// Update our local copy of any configuration that we use. -pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void { - self.resize_overlay.updateConfig(config); +pub fn getTitle(self: *Self) ?[:0]const u8 { + return self.surface.getTitle(); } -// unref removes the long-held reference to the gl_area and kicks off the -// deinit/destroy process for this surface. -pub fn unref(self: *Surface) void { - self.overlay.unref(); +pub fn getContentScale(self: *const Self) !apprt.ContentScale { + return self.surface.getContentScale(); } -pub fn destroy(self: *Surface, alloc: Allocator) void { - self.deinit(); - alloc.destroy(self); +pub fn getSize(self: *const Self) !apprt.SurfaceSize { + return self.surface.getSize(); } -pub fn primaryWidget(self: *Surface) *gtk.Widget { - return self.overlay.as(gtk.Widget); -} - -fn render(self: *Surface) !void { - try self.core_surface.renderer.drawFrame(true); -} - -/// Called by core surface to get the cgroup. -pub fn cgroup(self: *const Surface) ?[]const u8 { - return self.cgroup_path; -} - -/// Queue the inspector to render if we have one. -pub fn queueInspectorRender(self: *Surface) void { - if (self.inspector) |v| v.queueRender(); -} - -/// Invalidate the surface so that it forces a redraw on the next tick. -pub fn redraw(self: *Surface) void { - self.gl_area.queueRender(); -} - -/// Close this surface. -pub fn close(self: *Surface, process_active: bool) void { - self.closeWithConfirmation(process_active, .{ .surface = self }); -} - -/// Close this surface. -pub fn closeWithConfirmation(self: *Surface, process_active: bool, target: CloseDialog.Target) void { - self.setSplitZoom(false); - - if (!process_active) { - self.container.remove(); - return; - } - - CloseDialog.show(target) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -pub fn controlInspector( - self: *Surface, - mode: apprt.action.Inspector, -) void { - const show = switch (mode) { - .toggle => self.inspector == null, - .show => true, - .hide => false, - }; - - if (!show) { - if (self.inspector) |v| { - v.close(); - self.inspector = null; - } - - return; - } - - // If we already have an inspector, we don't need to show anything. - if (self.inspector != null) return; - self.inspector = inspectorpkg.Inspector.create( - self, - .{ .window = {} }, - ) catch |err| { - log.err("failed to control inspector err={}", .{err}); - return; - }; -} - -pub fn getContentScale(self: *const Surface) !apprt.ContentScale { - const gtk_scale: f32 = scale: { - const widget = self.gl_area.as(gtk.Widget); - // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we - // can support fractional scaling. - const scale = widget.getScaleFactor(); - if (scale <= 0) { - log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); - break :scale 1.0; - } - break :scale @floatFromInt(scale); - }; - - // Also scale using font-specific DPI, which is often exposed to the user - // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). - const xft_dpi_scale = xft_scale: { - // gtk-xft-dpi is font DPI multiplied by 1024. See - // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0; - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(c_int)); - settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); - const gtk_xft_dpi = value.getInt(); - - // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 - // See: - // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 - // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead - if (gtk_xft_dpi <= 0) { - log.warn("gtk-xft-dpi was not set, using default value", .{}); - break :xft_scale 1.0; - } - - // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by - // 1024, then divide by the default value (96) to derive a scale. Note - // gtk-xft-dpi can be fractional, so we use floating point math here. - const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024.0; - break :xft_scale xft_dpi / 96.0; - }; - - const scale = gtk_scale * xft_dpi_scale; - return .{ .x = scale, .y = scale }; -} - -pub fn getSize(self: *const Surface) !apprt.SurfaceSize { - return self.size; -} - -pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { - // If we've already become realized once then we ignore this - // request. The apprt initial_size action should only modify - // the physical size of the window during initialization. - // Subsequent actions are only informative in case we want to - // implement a "return to default size" action later. - if (self.realized) return; - - // If we are within a split, do not set the size. - if (self.container.split() != null) return; - - // This operation only makes sense if we're within a window view - // hierarchy and we're the first tab in the window. - const window = self.container.window() orelse return; - if (window.notebook.nPages() > 1) return; - - const gtk_window = window.window.as(gtk.Window); - - // Note: this doesn't properly take into account the window decorations. - // I'm not currently sure how to do that. - gtk_window.setDefaultSize(@intCast(width), @intCast(height)); -} - -pub fn setSizeLimits(self: *const Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { - - // There's no support for setting max size at the moment. - _ = max_; - - // If we are within a split, do not set the size. - if (self.container.split() != null) return; - - // This operation only makes sense if we're within a window view - // hierarchy and we're the first tab in the window. - const window = self.container.window() orelse return; - if (window.notebook.nPages() > 1) return; - - const widget = window.window.as(gtk.Widget); - - // Note: this doesn't properly take into account the window decorations. - // I'm not currently sure how to do that. - widget.setSizeRequest(@intCast(min.width), @intCast(min.height)); -} - -pub fn grabFocus(self: *Surface) void { - if (self.container.tab()) |tab| { - // If any other surface was focused and zoomed in, set it to non zoomed in - // so that self can grab focus. - if (tab.focus_child) |focus_child| { - if (focus_child.zoomed_in and focus_child != self) { - focus_child.setSplitZoom(false); - } - } - tab.focus_child = self; - } - - _ = self.gl_area.as(gtk.Widget).grabFocus(); - - self.updateTitleLabels(); -} - -fn updateTitleLabels(self: *Surface) void { - // If we have no title, then we have nothing to update. - const title = self.getTitle() orelse return; - - // If we have a tab and are the focused child, then we have to update the tab - if (self.container.tab()) |tab| { - if (tab.focus_child == self) tab.setTitleText(title); - } - - // If we have a window and are focused, then we have to update the window title. - if (self.container.window()) |window| { - const widget = self.gl_area.as(gtk.Widget); - if (widget.isFocus() != 0) { - // Changing the title somehow unhides our cursor. - // https://github.com/ghostty-org/ghostty/issues/1419 - // I don't know a way around this yet. I've tried re-hiding the - // cursor after setting the title but it doesn't work, I think - // due to some gtk event loop things... - window.setTitle(title); - } - } -} - -const zoom_title_prefix = "🔍 "; -pub const SetTitleSource = enum { user, terminal }; - -pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void { - const alloc = self.app.core_app.alloc; - - // Always allocate with the "🔍 " at the beginning and slice accordingly - // is the surface is zoomed in or not. - const copy: [:0]const u8 = copy: { - const new_title = try alloc.allocSentinel(u8, zoom_title_prefix.len + slice.len, 0); - @memcpy(new_title[0..zoom_title_prefix.len], zoom_title_prefix); - @memcpy(new_title[zoom_title_prefix.len..], slice); - break :copy new_title; - }; - errdefer alloc.free(copy); - - // The user has overridden the title - // We only want to update the terminal provided title so that it can be restored to the most recent state. - if (self.title_from_terminal != null and source == .terminal) { - alloc.free(self.title_from_terminal.?); - self.title_from_terminal = copy; - return; - } - - if (self.title_text) |old| alloc.free(old); - self.title_text = copy; - - // delay the title update to prevent flickering - if (self.update_title_timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove update title timer", .{}); - } - self.update_title_timer = null; - } - self.update_title_timer = glib.timeoutAdd(75, updateTitleTimerExpired, self); -} - -fn updateTitleTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Surface = @ptrCast(@alignCast(ud.?)); - - self.updateTitleLabels(); - self.update_title_timer = null; - - return 0; -} - -pub fn getTitle(self: *Surface) ?[:0]const u8 { - if (self.title_text) |title_text| { - return self.resolveTitle(title_text); - } - - return null; -} - -pub fn getTerminalTitle(self: *Surface) ?[:0]const u8 { - if (self.title_from_terminal) |title_text| { - return self.resolveTitle(title_text); - } - - return null; -} - -fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 { - return if (self.zoomed_in) - title - else - title[zoom_title_prefix.len..]; -} - -pub fn promptTitle(self: *Surface) !void { - if (!adw_version.atLeast(1, 5, 0)) return; - const window = self.container.window() orelse return; - - var builder = Builder.init("prompt-title-dialog", 1, 5); - defer builder.deinit(); - - const entry = builder.getObject(gtk.Entry, "title_entry").?; - entry.getBuffer().setText(self.getTitle() orelse "", -1); - - const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?; - dialog.choose(window.window.as(gtk.Widget), null, gtkPromptTitleResponse, self); -} - -/// Set the current working directory of the surface. -/// -/// In addition, update the tab's tooltip text, and if we are the focused child, -/// update the subtitle of the containing window. -pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void { - if (self.container.tab()) |tab| { - tab.setTooltipText(pwd); - - if (tab.focus_child == self) { - if (self.container.window()) |window| { - if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); - } - } - } - - const alloc = self.app.core_app.alloc; - - // Failing to set the surface's current working directory is not a big - // deal since we just used our slice parameter which is the same value. - if (self.pwd) |old| alloc.free(old); - self.pwd = alloc.dupeZ(u8, pwd) catch null; -} - -pub fn setMouseShape( - self: *Surface, - shape: terminal.MouseShape, -) !void { - const name: [:0]const u8 = switch (shape) { - .default => "default", - .help => "help", - .pointer => "pointer", - .context_menu => "context-menu", - .progress => "progress", - .wait => "wait", - .cell => "cell", - .crosshair => "crosshair", - .text => "text", - .vertical_text => "vertical-text", - .alias => "alias", - .copy => "copy", - .no_drop => "no-drop", - .move => "move", - .not_allowed => "not-allowed", - .grab => "grab", - .grabbing => "grabbing", - .all_scroll => "all-scroll", - .col_resize => "col-resize", - .row_resize => "row-resize", - .n_resize => "n-resize", - .e_resize => "e-resize", - .s_resize => "s-resize", - .w_resize => "w-resize", - .ne_resize => "ne-resize", - .nw_resize => "nw-resize", - .se_resize => "se-resize", - .sw_resize => "sw-resize", - .ew_resize => "ew-resize", - .ns_resize => "ns-resize", - .nesw_resize => "nesw-resize", - .nwse_resize => "nwse-resize", - .zoom_in => "zoom-in", - .zoom_out => "zoom-out", - }; - - const cursor = gdk.Cursor.newFromName(name.ptr, null) orelse { - log.warn("unsupported cursor name={s}", .{name}); - return; - }; - errdefer cursor.unref(); - - // Set our new cursor. We only do this if the cursor we currently - // have is NOT set to "none" because setting the cursor causes it - // to become visible again. - const widget = self.gl_area.as(gtk.Widget); - if (widget.getCursor() != self.app.cursor_none) { - widget.setCursor(cursor); - } - - // Free our existing cursor - if (self.cursor) |old| old.unref(); - self.cursor = cursor; -} - -/// Set the visibility of the mouse cursor. -pub fn setMouseVisibility(self: *Surface, visible: bool) void { - // Note in there that self.cursor or cursor_none may be null. That's - // not a problem because NULL is a valid argument for set cursor - // which means to just use the parent value. - const widget = self.gl_area.as(gtk.Widget); - - if (visible) { - widget.setCursor(self.cursor); - return; - } - - // Set our new cursor to the app "none" cursor - widget.setCursor(self.app.cursor_none); -} - -pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { - const uri = uri_ orelse { - if (self.url_widget) |*widget| { - widget.deinit(self.overlay); - self.url_widget = null; - } - - return; - }; - - // We need a null-terminated string - const alloc = self.app.core_app.alloc; - const uriZ = alloc.dupeZ(u8, uri) catch return; - defer alloc.free(uriZ); - - // If we have a URL widget already just change the text. - if (self.url_widget) |widget| { - widget.setText(uriZ); - return; - } - - self.url_widget = .init(self.overlay, uriZ); +pub fn getCursorPos(self: *const Self) !apprt.CursorPos { + return self.surface.getCursorPos(); } pub fn supportsClipboard( - self: *const Surface, + self: *const Self, clipboard_type: apprt.Clipboard, ) bool { _ = self; @@ -1229,1333 +70,34 @@ pub fn supportsClipboard( } pub fn clipboardRequest( - self: *Surface, + self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) !void { - // We allocate for userdata for the clipboard request. Not ideal but - // clipboard requests aren't common so probably not a big deal. - const alloc = self.app.core_app.alloc; - const ud_ptr = try alloc.create(ClipboardRequest); - errdefer alloc.destroy(ud_ptr); - ud_ptr.* = .{ .self = self, .state = state }; - - // Start our async request - const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return; - - clipboard.readTextAsync(null, gtkClipboardRead, ud_ptr); -} - -pub fn setClipboardString( - self: *Surface, - val: [:0]const u8, - clipboard_type: apprt.Clipboard, - confirm: bool, -) !void { - if (!confirm) { - const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return; - clipboard.setText(val); - - // We only toast if we are copying to the standard clipboard. - if (clipboard_type == .standard and - self.app.config.@"app-notifications".@"clipboard-copy") - toast: { - const window = self.container.window() orelse break :toast; - - if (val.len > 0) - window.sendToast(i18n._("Copied to clipboard")) - else - window.sendToast(i18n._("Cleared clipboard")); - } - return; - } - - ClipboardConfirmationWindow.create( - self.app, - val, - &self.core_surface, - .{ .osc_52_write = clipboard_type }, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; -} - -const ClipboardRequest = struct { - self: *Surface, - state: apprt.ClipboardRequest, -}; - -fn gtkClipboardRead( - source: ?*gobject.Object, - res: *gio.AsyncResult, - ud: ?*anyopaque, -) callconv(.c) void { - const clipboard = gobject.ext.cast(gdk.Clipboard, source orelse return) orelse return; - const req: *ClipboardRequest = @ptrCast(@alignCast(ud orelse return)); - const self = req.self; - const alloc = self.app.core_app.alloc; - defer alloc.destroy(req); - - var gerr: ?*glib.Error = null; - const cstr_ = clipboard.readTextFinish(res, &gerr); - if (gerr) |err| { - defer err.free(); - log.warn("failed to read clipboard err={s}", .{err.f_message orelse "(no message)"}); - return; - } - const cstr = cstr_ orelse return; - defer glib.free(cstr); - const str = std.mem.sliceTo(cstr, 0); - - self.core_surface.completeClipboardRequest( - req.state, - str, - false, - ) catch |err| switch (err) { - error.UnsafePaste, - error.UnauthorizedPaste, - => { - // Create a dialog and ask the user if they want to paste anyway. - ClipboardConfirmationWindow.create( - self.app, - str, - &self.core_surface, - req.state, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; - return; - }, - - else => log.err("failed to complete clipboard request err={}", .{err}), - }; -} - -fn getClipboard(widget: *gtk.Widget, clipboard: apprt.Clipboard) ?*gdk.Clipboard { - return switch (clipboard) { - .standard => widget.getClipboard(), - .selection, .primary => widget.getPrimaryClipboard(), - }; -} - -pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { - return self.cursor_pos; -} - -pub fn showDesktopNotification( - self: *Surface, - title: []const u8, - body: []const u8, -) !void { - // Set a default title if we don't already have one - const t = switch (title.len) { - 0 => "Ghostty", - else => title, - }; - - const notification = gio.Notification.new(t); - defer notification.unref(); - notification.setBody(body); - - const icon = gio.ThemedIcon.new(build_config.bundle_id); - defer icon.unref(); - - notification.setIcon(icon); - - const pointer = glib.Variant.newUint64(@intFromPtr(&self.core_surface)); - notification.setDefaultActionAndTargetValue("app.present-surface", pointer); - - const app = self.app.app.as(gio.Application); - - // We set the notification ID to the body content. If the content is the - // same, this notification may replace a previous notification - app.sendNotification(body.ptr, notification); -} - -fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl surface realized", .{}); - - // We need to make the context current so we can call GL functions. - gl_area.makeCurrent(); - if (gl_area.getError()) |err| { - log.err("surface failed to realize: {s}", .{err.f_message orelse "(no message)"}); - log.warn("this error is usually due to a driver or gtk bug", .{}); - log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{}); - return; - } - - // realize means that our OpenGL context is ready, so we can now - // initialize the core surface which will setup the renderer. - self.realize() catch |err| { - // TODO: we need to destroy the GL area here. - log.err("surface failed to realize: {}", .{err}); - return; - }; - - // When we have a realized surface, we also attach our input method context. - // We do this here instead of init because this allows us to release the ref - // to the GLArea when we unrealized. - self.im_context.as(gtk.IMContext).setClientWidget(self.overlay.as(gtk.Widget)); -} - -/// This is called when the underlying OpenGL resources must be released. -/// This is usually due to the OpenGL area changing GDK surfaces. -fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl surface unrealized", .{}); - - // See gtkRealize for why we do this here. - self.im_context.as(gtk.IMContext).setClientWidget(null); - - // There is no guarantee that our GLArea context is current - // when unrealize is emitted, so we need to make it current. - gl_area.makeCurrent(); - if (gl_area.getError()) |err| { - // I don't know a scenario this can happen, but it means - // we probably leaked memory because displayUnrealized - // below frees resources that aren't specifically OpenGL - // related. I didn't make the OpenGL renderer handle this - // scenario because I don't know if its even possible - // under valid circumstances, so let's log. - log.warn( - "gl_area_make_current failed in unrealize msg={s}", - .{err.f_message orelse "(no message)"}, - ); - log.warn("OpenGL resources and memory likely leaked", .{}); - return; - } else { - self.core_surface.renderer.displayUnrealized(); - } -} - -/// render signal -fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.c) c_int { - self.render() catch |err| { - log.err("surface failed to render: {}", .{err}); - return 0; - }; - - return 1; -} - -/// resize signal -fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface) callconv(.c) void { - // Some debug output to help understand what GTK is telling us. - { - const scale_factor = scale: { - const widget = gl_area.as(gtk.Widget); - break :scale widget.getScaleFactor(); - }; - - const window_scale_factor = scale: { - const window = self.container.window() orelse break :scale 0; - const gtk_window = window.window.as(gtk.Window); - const gtk_native = gtk_window.as(gtk.Native); - const gdk_surface = gtk_native.getSurface() orelse break :scale 0; - break :scale gdk_surface.getScaleFactor(); - }; - - log.debug("gl resize width={} height={} scale={} window_scale={}", .{ - width, - height, - scale_factor, - window_scale_factor, - }); - } - - self.size = .{ - .width = @intCast(width), - .height = @intCast(height), - }; - - // We also update the content scale because there is no signal for - // content scale change and it seems to trigger a resize event. - if (self.getContentScale()) |scale| { - self.core_surface.contentScaleCallback(scale) catch |err| { - log.err("error in content scale callback err={}", .{err}); - return; - }; - } else |_| {} - - // Call the primary callback. - if (self.realized) { - self.core_surface.sizeCallback(self.size) catch |err| { - log.err("error in size callback err={}", .{err}); - return; - }; - - if (self.container.window()) |window| { - window.winproto.resizeEvent() catch |err| { - log.warn("failed to notify window protocol of resize={}", .{err}); - }; - } - - self.resize_overlay.maybeShow(); - } -} - -/// "destroy" signal for surface -fn gtkDestroy(_: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl destroy", .{}); - - const alloc = self.app.core_app.alloc; - self.deinit(); - alloc.destroy(self); -} - -/// Scale x/y by the GDK device scale. -fn scaledCoordinates( - self: *const Surface, - x: f64, - y: f64, -) struct { - x: f64, - y: f64, -} { - const gl_are_widget = self.gl_area.as(gtk.Widget); - const scale_factor: f64 = @floatFromInt( - gl_are_widget.getScaleFactor(), - ); - - return .{ - .x = x * scale_factor, - .y = y * scale_factor, - }; -} - -fn gtkMouseDown( - gesture: *gtk.GestureClick, - _: c_int, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) void { - const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; - - const gtk_mods = event.getModifierState(); - - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - const mods = gtk_key.translateMods(gtk_mods); - - // If we don't have focus, grab it. - const gl_area_widget = self.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0) { - self.grabFocus(); - } - - const consumed = self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - - // If a right click isn't consumed, mouseButtonCallback selects the hovered - // word and returns false. We can use this to handle the context menu - // opening under normal scenarios. - if (!consumed and button == .right) { - self.context_menu.popupAt(@intFromFloat(x), @intFromFloat(y)); - } -} - -fn gtkMouseUp( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *Surface, -) callconv(.c) void { - const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; - - const gtk_mods = event.getModifierState(); - - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - const mods = gtk_key.translateMods(gtk_mods); - - _ = self.core_surface.mouseButtonCallback(.release, button, mods) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; -} - -fn gtkMouseMotion( - ec: *gtk.EventControllerMotion, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) void { - const event = ec.as(gtk.EventController).getCurrentEvent() orelse return; - - const scaled = self.scaledCoordinates(x, y); - - const pos: apprt.CursorPos = .{ - .x = @floatCast(scaled.x), - .y = @floatCast(scaled.y), - }; - - // There seem to be at least two cases where GTK issues a mouse motion - // event without the cursor actually moving: - // 1. GLArea is resized under the mouse. This has the unfortunate - // side effect of causing focus to potentially change when - // `focus-follows-mouse` is enabled. - // 2. The window title is updated. This can cause the mouse to unhide - // incorrectly when hide-mouse-when-typing is enabled. - // To prevent incorrect behavior, we'll only grab focus and - // continue with callback logic if the cursor has actually moved. - const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and - @abs(self.cursor_pos.y - pos.y) < 1; - - if (!is_cursor_still) { - // If we don't have focus, and we want it, grab it. - const gl_area_widget = self.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0 and self.app.config.@"focus-follows-mouse") { - self.grabFocus(); - } - - // Our pos changed, update - self.cursor_pos = pos; - - // Get our modifiers - const gtk_mods = event.getModifierState(); - const mods = gtk_key.translateMods(gtk_mods); - - self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { - log.err("error in cursor pos callback err={}", .{err}); - return; - }; - } -} - -fn gtkMouseLeave( - ec_motion: *gtk.EventControllerMotion, - self: *Surface, -) callconv(.c) void { - const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return; - - // Get our modifiers - const gtk_mods = event.getModifierState(); - const mods = gtk_key.translateMods(gtk_mods); - self.core_surface.cursorPosCallback(.{ .x = -1, .y = -1 }, mods) catch |err| { - log.err("error in cursor pos callback err={}", .{err}); - return; - }; -} - -fn gtkMouseScrollPrecisionBegin( - _: *gtk.EventControllerScroll, - self: *Surface, -) callconv(.c) void { - self.precision_scroll = true; -} - -fn gtkMouseScrollPrecisionEnd( - _: *gtk.EventControllerScroll, - self: *Surface, -) callconv(.c) void { - self.precision_scroll = false; -} - -fn gtkMouseScroll( - _: *gtk.EventControllerScroll, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) c_int { - const scaled = self.scaledCoordinates(x, y); - - // GTK doesn't support any of the scroll mods. - const scroll_mods: input.ScrollMods = .{ .precision = self.precision_scroll }; - // Multiply precision scrolls by 10 to get a better response from touchpad scrolling - const multiplier: f64 = if (self.precision_scroll) 10.0 else 1.0; - - self.core_surface.scrollCallback( - // We invert because we apply natural scrolling to the values. - // This behavior has existed for years without Linux users complaining - // but I suspect we'll have to make this configurable in the future - // or read a system setting. - scaled.x * -1 * multiplier, - scaled.y * -1 * multiplier, - scroll_mods, - ) catch |err| { - log.err("error in scroll callback err={}", .{err}); - return 0; - }; - - return 1; -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *Surface, -) callconv(.c) c_int { - return @intFromBool(self.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )); -} - -fn gtkKeyReleased( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - state: gdk.ModifierType, - self: *Surface, -) callconv(.c) void { - _ = self.keyEvent( - .release, - ec_key, - keyval, - keycode, + try self.surface.clipboardRequest( + clipboard_type, state, ); } -/// Key press event (press or release). -/// -/// At a high level, we want to construct an `input.KeyEvent` and -/// pass that to `keyCallback`. At a low level, this is more complicated -/// than it appears because we need to construct all of this information -/// and its not given to us. -/// -/// For all events, we run the GdkEvent through the input method context. -/// This allows the input method to capture the event and trigger -/// callbacks such as preedit, commit, etc. -/// -/// There are a couple important aspects to the prior paragraph: we must -/// send ALL events through the input method context. This is because -/// input methods use both key press and key release events to determine -/// the state of the input method. For example, fcitx uses key release -/// events on modifiers (i.e. ctrl+shift) to switch the input method. -/// -/// We set some state to note we're in a key event (self.in_keyevent) -/// because some of the input method callbacks change behavior based on -/// this state. For example, we don't want to send character events -/// like "a" via the input "commit" event if we're actively processing -/// a keypress because we'd lose access to the keycode information. -/// However, a "commit" event may still happen outside of a keypress -/// event from e.g. a tablet or on-screen keyboard. -/// -/// Finally, we take all of the information in order to determine if we have -/// a unicode character or if we have to map the keyval to a code to -/// get the underlying logical key, etc. -/// -/// Then we can emit the keyCallback. -pub fn keyEvent( - self: *Surface, - action: input.Action, - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, -) bool { - // log.warn("GTKIM: keyEvent action={}", .{action}); - const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; - const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; - - // The block below is all related to input method handling. See the function - // comment for some high level details and then the comments within - // the block for more specifics. - { - // This can trigger an input method so we need to notify the im context - // where the cursor is so it can render the dropdowns in the correct - // place. - const ime_point = self.core_surface.imePoint(); - self.im_context.as(gtk.IMContext).setCursorLocation(&.{ - .f_x = @intFromFloat(ime_point.x), - .f_y = @intFromFloat(ime_point.y), - .f_width = 1, - .f_height = 1, - }); - - // We note that we're in a keypress because we want some logic to - // depend on this. For example, we don't want to send character events - // like "a" via the input "commit" event if we're actively processing - // a keypress because we'd lose access to the keycode information. - // - // We have to maintain some additional state here of whether we - // were composing because different input methods call the callbacks - // in different orders. For example, ibus calls commit THEN preedit - // end but simple calls preedit end THEN commit. - self.in_keyevent = if (self.im_composing) .composing else .not_composing; - defer self.in_keyevent = .false; - - // Pass the event through the input method which returns true if handled. - // Confusingly, not all events handled by the input method result - // in this returning true so we have to maintain some additional - // state about whether we were composing or not to determine if - // we should proceed with key encoding. - // - // Cases where the input method does not mark the event as handled: - // - // - If we change the input method via keypress while we have preedit - // text, the input method will commit the pending text but will not - // mark it as handled. We use the `.composing` state to detect - // this case. - // - // - If we switch input methods (i.e. via ctrl+shift with fcitx), - // the input method will handle the key release event but will not - // mark it as handled. I don't know any way to detect this case so - // it will result in a key event being sent to the key callback. - // For Kitty text encoding, this will result in modifiers being - // triggered despite being technically consumed. At the time of - // writing, both Kitty and Alacritty have the same behavior. I - // know of no way to fix this. - const im_handled = self.im_context.as(gtk.IMContext).filterKeypress(event) != 0; - // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ - // im_handled, - // self.im_len, - // self.im_composing, - // }); - - // If the input method handled the event, you would think we would - // never proceed with key encoding for Ghostty but that is not the - // case. Input methods will handle basic character encoding like - // typing "a" and we want to associate that with the key event. - // So we have to check additional state to determine if we exit. - if (im_handled) { - // If we are composing then we're in a preedit state and do - // not want to encode any keys. For example: type a deadkey - // such as single quote on a US international keyboard layout. - if (self.im_composing) return true; - - // If we were composing and now we're not it means that we committed - // the text. We also don't want to encode a key event for this. - // Example: enable Japanese input method, press "konn" and then - // press enter. The final enter should not be encoded and "konn" - // (in hiragana) should be written as "こん". - if (self.in_keyevent == .composing) return true; - - // Not composing and our input method buffer is empty. This could - // mean that the input method reacted to this event by activating - // an onscreen keyboard or something equivalent. We don't know. - // But the input method handled it and didn't give us text so - // we will just assume we should not encode this. This handles a - // real scenario when ibus starts the emoji input method - // (super+.). - if (self.im_len == 0) return true; - } - - // At this point, for the sake of explanation of internal state: - // it is possible that im_len > 0 and im_composing == false. This - // means that we received a commit event from the input method that - // we want associated with the key event. This is common: its how - // basic character translation for simple inputs like "a" work. - } - - // We always reset the length of the im buffer. There's only one scenario - // we reach this point with im_len > 0 and that's if we received a commit - // event from the input method. We don't want to keep that state around - // since we've handled it here. - defer self.im_len = 0; - - // Get the keyvals for this event. - const keyval_unicode = gdk.keyvalToUnicode(keyval); - const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( - self.gl_area.as(gtk.Widget), - key_event, - keycode, +pub fn setClipboardString( + self: *Self, + val: [:0]const u8, + clipboard_type: apprt.Clipboard, + confirm: bool, +) !void { + self.surface.setClipboardString( + val, + clipboard_type, + confirm, ); - - // We want to get the physical unmapped key to process physical keybinds. - // (These are keybinds explicitly marked as requesting physical mapping). - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .unidentified; - - // Get our modifier for the event - const mods: input.Mods = gtk_key.eventMods( - event, - physical_key, - gtk_mods, - action, - &self.app.winproto, - ); - - // Get our consumed modifiers - const consumed_mods: input.Mods = consumed: { - const T = @typeInfo(gdk.ModifierType); - std.debug.assert(T.@"struct".layout == .@"packed"); - const I = T.@"struct".backing_integer.?; - - const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK); - break :consumed gtk_key.translateMods(@bitCast(masked)); - }; - - // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ - // key, - // keyval, - // physical_key, - // self.im_composing, - // self.im_len, - // mods, - // }); - - // If we have no UTF-8 text, we try to convert our keyval to - // a text value. We have to do this because GTK will not process - // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". - // But the keyval is set correctly so we can at least extract that. - if (self.im_len == 0 and keyval_unicode > 0) im: { - if (std.math.cast(u21, keyval_unicode)) |cp| { - // We don't want to send control characters as IM - // text. Control characters are handled already by - // the encoder directly. - if (cp < 0x20) break :im; - - if (std.unicode.utf8Encode(cp, &self.im_buf)) |len| { - self.im_len = len; - } else |_| {} - } - } - - // Invoke the core Ghostty logic to handle this input. - const effect = self.core_surface.keyCallback(.{ - .action = action, - .key = physical_key, - .mods = mods, - .consumed_mods = consumed_mods, - .composing = self.im_composing, - .utf8 = self.im_buf[0..self.im_len], - .unshifted_codepoint = keyval_unicode_unshifted, - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return false; - }; - - switch (effect) { - .closed => return true, - .ignored => {}, - .consumed => if (action == .press or action == .repeat) { - // If we were in the composing state then we reset our context. - // We do NOT want to reset if we're not in the composing state - // because there is other IME state that we want to preserve, - // such as quotation mark ordering for Chinese input. - if (self.im_composing) { - self.im_context.as(gtk.IMContext).reset(); - self.core_surface.preeditCallback(null) catch {}; - } - - return true; - }, - } - - return false; } -fn gtkInputPreeditStart( - _: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // log.warn("GTKIM: preedit start", .{}); - - // Start our composing state for the input method and reset our - // input buffer to empty. - self.im_composing = true; - self.im_len = 0; +pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + return try self.surface.defaultTermioEnv(); } -fn gtkInputPreeditChanged( - ctx: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // Any preedit change should mark that we're composing. Its possible this - // is false using fcitx5-hangul and typing "dkssud" ("안녕"). The - // second "s" results in a "commit" for "안" which sets composing to false, - // but then immediately sends a preedit change for the next symbol. With - // composing set to false we won't commit this text. Therefore, we must - // ensure it is set here. - self.im_composing = true; - - // Get our pre-edit string that we'll use to show the user. - var buf: [*:0]u8 = undefined; - ctx.as(gtk.IMContext).getPreeditString(&buf, null, null); - defer glib.free(buf); - - const str = std.mem.sliceTo(buf, 0); - - // Update our preedit state in Ghostty core - // log.warn("GTKIM: preedit change str={s}", .{str}); - self.core_surface.preeditCallback(str) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; -} - -fn gtkInputPreeditEnd( - _: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // log.warn("GTKIM: preedit end", .{}); - - // End our composing state for GTK, allowing us to commit the text. - self.im_composing = false; - - // End our preedit state in Ghostty core - self.core_surface.preeditCallback(null) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; -} - -fn gtkInputCommit( - _: *gtk.IMMulticontext, - bytes: [*:0]u8, - self: *Surface, -) callconv(.c) void { - const str = std.mem.sliceTo(bytes, 0); - - // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{ - // self.im_composing, - // self.in_keyevent, - // str, - // }); - - // We need to handle commit specially if we're in a key event. - // Specifically, GTK will send us a commit event for basic key - // encodings like "a" (on a US layout keyboard). We don't want - // to treat this as IME committed text because we want to associate - // it with a key event (i.e. "a" key press). - switch (self.in_keyevent) { - // If we're not in a key event then this commit is from - // some other source (i.e. on-screen keyboard, tablet, etc.) - // and we want to commit the text to the core surface. - .false => {}, - - // If we're in a composing state and in a key event then this - // key event is resulting in a commit of multiple keypresses - // and we don't want to encode it alongside the keypress. - .composing => {}, - - // If we're not composing then this commit is just a normal - // key encoding and we want our key event to handle it so - // that Ghostty can be aware of the key event alongside - // the text. - .not_composing => { - if (str.len > self.im_buf.len) { - log.warn("not enough buffer space for input method commit", .{}); - return; - } - - // Copy our committed text to the buffer - @memcpy(self.im_buf[0..str.len], str); - self.im_len = @intCast(str.len); - - // log.debug("input commit len={}", .{self.im_len}); - return; - }, - } - - // If we reach this point from above it means we're composing OR - // not in a keypress. In either case, we want to commit the text - // given to us because that's what GTK is asking us to do. If we're - // not in a keypress it means that this commit came via a non-keyboard - // event (i.e. on-screen keyboard, tablet of some kind, etc.). - - // Committing ends composing state - self.im_composing = false; - - // End our preedit state. Well-behaved input methods do this for us - // by triggering a preedit-end event but some do not (ibus 1.5.29). - self.core_surface.preeditCallback(null) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; - - // Send the text to the core surface, associated with no key (an - // invalid key, which should produce no PTY encoding). - _ = self.core_surface.keyCallback(.{ - .action = .press, - .key = .unidentified, - .mods = .{}, - .consumed_mods = .{}, - .composing = false, - .utf8 = str, - }) catch |err| { - log.warn("error in key callback err={}", .{err}); - return; - }; -} - -fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void { - if (!self.realized) return; - - // Notify our IM context - self.im_context.as(gtk.IMContext).focusIn(); - - // Remove the unfocused widget overlay, if we have one - if (self.unfocused_widget) |widget| { - self.overlay.removeOverlay(widget); - self.unfocused_widget = null; - } - - if (self.pwd) |pwd| { - if (self.container.window()) |window| { - if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); - } - } - - // Notify our surface - self.core_surface.focusCallback(true) catch |err| { - log.err("error in focus callback err={}", .{err}); - return; - }; -} - -fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void { - if (!self.realized) return; - - // Notify our IM context - self.im_context.as(gtk.IMContext).focusOut(); - - // We only try dimming the surface if we are a split - switch (self.container) { - .split_br, - .split_tl, - => self.dimSurface(), - else => {}, - } - - self.core_surface.focusCallback(false) catch |err| { - log.err("error in focus callback err={}", .{err}); - return; - }; -} - -/// Adds the unfocused_widget to the overlay. If the unfocused_widget has -/// already been added, this is a no-op. -pub fn dimSurface(self: *Surface) void { - _ = self.container.window() orelse { - log.warn("dimSurface invalid for container={}", .{self.container}); - return; - }; - - // Don't dim surface if context menu is open. - // This means we got unfocused due to it opening. - if (self.context_menu.isVisible()) return; - - // If there's already an unfocused_widget do nothing; - if (self.unfocused_widget) |_| return; - - self.unfocused_widget = unfocused_widget: { - const drawing_area = gtk.DrawingArea.new(); - const unfocused_widget = drawing_area.as(gtk.Widget); - unfocused_widget.addCssClass("unfocused-split"); - self.overlay.addOverlay(unfocused_widget); - break :unfocused_widget unfocused_widget; - }; -} - -fn translateMouseButton(button: c_uint) input.MouseButton { - return switch (button) { - 1 => .left, - 2 => .middle, - 3 => .right, - 4 => .four, - 5 => .five, - 6 => .six, - 7 => .seven, - 8 => .eight, - 9 => .nine, - 10 => .ten, - 11 => .eleven, - else => .unknown, - }; -} - -pub fn present(self: *Surface) void { - if (self.container.window()) |window| { - if (self.container.tab()) |tab| { - if (window.notebook.getTabPosition(tab)) |position| - _ = window.notebook.gotoNthTab(position); - } - window.window.as(gtk.Window).present(); - } - - self.grabFocus(); -} - -fn detachFromSplit(self: *Surface) void { - const split = self.container.split() orelse return; - switch (self.container.splitSide() orelse unreachable) { - .top_left => split.detachTopLeft(), - .bottom_right => split.detachBottomRight(), - } -} - -fn attachToSplit(self: *Surface) void { - const split = self.container.split() orelse return; - split.updateChildren(); -} - -pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void { - if (new_split_zoom == self.zoomed_in) return; - const tab = self.container.tab() orelse return; - - const tab_widget = tab.elem.widget(); - const surface_widget = self.primaryWidget(); - - if (new_split_zoom) { - self.detachFromSplit(); - tab.box.remove(tab_widget); - tab.box.append(surface_widget); - } else { - tab.box.remove(surface_widget); - self.attachToSplit(); - tab.box.append(tab_widget); - } - - self.zoomed_in = new_split_zoom; - self.grabFocus(); -} - -pub fn toggleSplitZoom(self: *Surface) void { - self.setSplitZoom(!self.zoomed_in); -} - -/// Handle items being dropped on our surface. -fn gtkDrop( - _: *gtk.DropTarget, - value: *gobject.Value, - _: f64, - _: f64, - self: *Surface, -) callconv(.c) c_int { - const alloc = self.app.core_app.alloc; - - if (g_value_holds(value, gdk.FileList.getGObjectType())) { - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - - var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{ - .child_writer = data.writer(), - }; - const writer = shell_escape_writer.writer(); - - const list: ?*glib.SList = list: { - const unboxed = value.getBoxed() orelse return 0; - const fl: *gdk.FileList = @ptrCast(@alignCast(unboxed)); - break :list fl.getFiles(); - }; - defer if (list) |v| v.free(); - - { - var current: ?*glib.SList = list; - while (current) |item| : (current = item.f_next) { - const file: *gio.File = @ptrCast(@alignCast(item.f_data orelse continue)); - const path = file.getPath() orelse continue; - const slice = std.mem.span(path); - defer glib.free(path); - - writer.writeAll(slice) catch |err| { - log.err("unable to write path to buffer: {}", .{err}); - continue; - }; - writer.writeAll("\n") catch |err| { - log.err("unable to write to buffer: {}", .{err}); - continue; - }; - } - } - - const string = data.toOwnedSliceSentinel(0) catch |err| { - log.err("unable to convert to a slice: {}", .{err}); - return 0; - }; - defer alloc.free(string); - - self.doPaste(string); - - return 1; - } - - if (g_value_holds(value, gio.File.getGObjectType())) { - const object = value.getObject() orelse return 0; - const file = gobject.ext.cast(gio.File, object) orelse return 0; - const path = file.getPath() orelse return 0; - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - - var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{ - .child_writer = data.writer(), - }; - const writer = shell_escape_writer.writer(); - writer.writeAll(std.mem.span(path)) catch |err| { - log.err("unable to write path to buffer: {}", .{err}); - return 0; - }; - writer.writeAll("\n") catch |err| { - log.err("unable to write to buffer: {}", .{err}); - return 0; - }; - - const string = data.toOwnedSliceSentinel(0) catch |err| { - log.err("unable to convert to a slice: {}", .{err}); - return 0; - }; - defer alloc.free(string); - - self.doPaste(string); - - return 1; - } - - if (g_value_holds(value, gobject.ext.types.string)) { - if (value.getString()) |string| { - const text = std.mem.span(string); - if (text.len > 0) self.doPaste(text); - } - return 1; - } - - return 1; -} - -fn doPaste(self: *Surface, data: [:0]const u8) void { - if (data.len == 0) return; - - self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) { - error.UnsafePaste, - error.UnauthorizedPaste, - => { - ClipboardConfirmationWindow.create( - self.app, - data, - &self.core_surface, - .paste, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; - }, - error.OutOfMemory, - error.NoSpaceLeft, - => log.err("failed to complete clipboard request err={}", .{err}), - }; -} - -pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { - const alloc = self.app.core_app.alloc; - var env = try internal_os.getEnvMap(alloc); - errdefer env.deinit(); - - // Don't leak these GTK environment variables to child processes. - env.remove("GDK_DEBUG"); - env.remove("GDK_DISABLE"); - env.remove("GSK_RENDERER"); - - // Remove some environment variables that are set when Ghostty is launched - // from a `.desktop` file, by D-Bus activation, or systemd. - env.remove("GIO_LAUNCHED_DESKTOP_FILE"); - env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); - env.remove("DBUS_STARTER_ADDRESS"); - env.remove("DBUS_STARTER_BUS_TYPE"); - env.remove("INVOCATION_ID"); - env.remove("JOURNAL_STREAM"); - env.remove("NOTIFY_SOCKET"); - - // Unset environment varies set by snaps if we're running in a snap. - // This allows Ghostty to further launch additional snaps. - if (env.get("SNAP")) |_| { - env.remove("SNAP"); - env.remove("DRIRC_CONFIGDIR"); - env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS"); - env.remove("__EGL_VENDOR_LIBRARY_DIRS"); - env.remove("LD_LIBRARY_PATH"); - env.remove("LIBGL_DRIVERS_PATH"); - env.remove("LIBVA_DRIVERS_PATH"); - env.remove("VK_LAYER_PATH"); - env.remove("XLOCALEDIR"); - env.remove("GDK_PIXBUF_MODULEDIR"); - env.remove("GDK_PIXBUF_MODULE_FILE"); - env.remove("GTK_PATH"); - } - - if (self.container.window()) |window| { - // On some window protocols we might want to add specific - // environment variables to subprocesses, such as WINDOWID on X11. - try window.winproto.addSubprocessEnv(&env); - } - - return env; -} - -/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's -/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it. -fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool { - if (value_) |value| { - if (value.f_g_type == g_type) return true; - return gobject.typeCheckValueHolds(value, g_type) != 0; - } - return false; -} - -fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { - if (!adw_version.supportsDialogs()) return; - const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?; - const self: *Surface = @ptrCast(@alignCast(ud)); - - const response = dialog.chooseFinish(result); - if (std.mem.orderZ(u8, "ok", response) == .eq) { - const title_entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; - const title = std.mem.span(title_entry.getBuffer().getText()); - - // if the new title is empty and the user has set the title previously, restore the terminal provided title - if (title.len == 0) { - if (self.getTerminalTitle()) |terminal_title| { - self.setTitle(terminal_title, .user) catch |err| { - log.err("failed to set title={}", .{err}); - }; - self.app.core_app.alloc.free(self.title_from_terminal.?); - self.title_from_terminal = null; - } - } else if (title.len > 0) { - // if this is the first time the user is setting the title, save the current terminal provided title - if (self.title_from_terminal == null and self.title_text != null) { - self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { - error.OutOfMemory => { - log.err("failed to allocate memory for title={}", .{err}); - return; - }, - }; - } - - self.setTitle(title, .user) catch |err| { - log.err("failed to set title={}", .{err}); - }; - } - } -} - -pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { - switch (value) { - .on => self.is_secure_input = true, - .off => self.is_secure_input = false, - .toggle => self.is_secure_input = !self.is_secure_input, - } -} - -pub fn ringBell(self: *Surface) !void { - const features = self.app.config.@"bell-features"; - const window = self.container.window() orelse { - log.warn("failed to ring bell: surface is not attached to any window", .{}); - return; - }; - - // System beep - if (features.system) system: { - const surface = window.window.as(gtk.Native).getSurface() orelse break :system; - surface.beep(); - } - - if (features.audio) audio: { - // Play a user-specified audio file. - - const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) { - .optional => |path| .{ path, false }, - .required => |path| .{ path, true }, - }; - - const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0); - - std.debug.assert(std.fs.path.isAbsolute(pathname)); - const media_file = gtk.MediaFile.newForFilename(pathname); - - if (required) { - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - gtkStreamError, - null, - .{ .detail = "error" }, - ); - } - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - gtkStreamEnded, - null, - .{ .detail = "ended" }, - ); - - const media_stream = media_file.as(gtk.MediaStream); - media_stream.setVolume(volume); - media_stream.play(); - } - - if (features.attention) { - // Request user attention - window.winproto.setUrgent(true) catch |err| { - log.err("failed to request user attention={}", .{err}); - }; - } - - // Mark tab as needing attention - if (self.container.tab()) |tab| tab: { - const page = window.notebook.getTabPage(tab) orelse break :tab; - - // Need attention if we're not the currently selected tab - if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); - } -} - -/// Handle a stream that is in an error state. -fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { - const path = path: { - const file = media_file.getFile() orelse break :path null; - break :path file.getPath(); - }; - defer if (path) |p| glib.free(p); - - const media_stream = media_file.as(gtk.MediaStream); - const err = media_stream.getError() orelse return; - - log.warn("error playing bell from {s}: {s} {d} {s}", .{ - path orelse "<>", - glib.quarkToString(err.f_domain), - err.f_code, - err.f_message orelse "", - }); -} - -/// Stream is finished, release the memory. -fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { - media_file.unref(); -} - -/// Show native GUI element with a notification that the child process has -/// closed. Return `true` if we are able to show the GUI notification, and -/// `false` if we are not. -pub fn showChildExited(self: *Surface, info: apprt.surface.Message.ChildExited) error{}!bool { - if (!adw_version.supportsBanner()) return false; - - const warning_text, const css_class = if (info.exit_code == 0) - .{ i18n._("Command succeeded"), "child_exited_normally" } - else - .{ i18n._("Command failed"), "child_exited_abnormally" }; - - const banner = adw.Banner.new(warning_text); - banner.setRevealed(1); - banner.setButtonLabel(i18n._("Close")); - - _ = adw.Banner.signals.button_clicked.connect( - banner, - *Surface, - showChildExitedButtonClosed, - self, - .{}, - ); - - const banner_widget = banner.as(gtk.Widget); - banner_widget.setHalign(.fill); - banner_widget.setValign(.end); - banner_widget.addCssClass(css_class); - - self.overlay.addOverlay(banner_widget); - - return true; -} - -fn showChildExitedButtonClosed(_: *adw.Banner, self: *Surface) callconv(.c) void { - self.close(false); +/// Redraw the inspector for our surface. +pub fn redrawInspector(self: *Self) void { + self.surface.redrawInspector(); } diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig deleted file mode 100644 index c32fa19fc..000000000 --- a/src/apprt/gtk/Tab.zig +++ /dev/null @@ -1,171 +0,0 @@ -//! The state associated with a single tab in the window. -//! -//! A tab can contain one or more terminals due to splits. -const Tab = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const font = @import("../../font/main.zig"); -const input = @import("../../input.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const Surface = @import("Surface.zig"); -const Window = @import("Window.zig"); -const CloseDialog = @import("CloseDialog.zig"); - -const log = std.log.scoped(.gtk); - -pub const GHOSTTY_TAB = "ghostty_tab"; - -/// The window that owns this tab. -window: *Window, - -/// The tab label. The tab label is the text that appears on the tab. -label_text: *gtk.Label, - -/// We'll put our children into this box instead of packing them -/// directly, so that we can send the box into `c.g_signal_connect_data` -/// for the close button -box: *gtk.Box, - -/// The element of this tab so that we can handle splits and so on. -elem: Surface.Container.Elem, - -// We'll update this every time a Surface gains focus, so that we have it -// when we switch to another Tab. Then when we switch back to this tab, we -// can easily re-focus that terminal. -focus_child: ?*Surface, - -pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab { - var tab = try alloc.create(Tab); - errdefer alloc.destroy(tab); - try tab.init(window, parent_); - return tab; -} - -/// Initialize the tab, create a surface, and add it to the window. "self" needs -/// to be a stable pointer, since it is used for GTK events. -pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { - self.* = .{ - .window = window, - .label_text = undefined, - .box = undefined, - .elem = undefined, - .focus_child = null, - }; - - // Create a Box in which we'll later keep either Surface or Split. Using a - // box makes it easier to maintain the tab contents because we never need to - // change the root widget of the notebook page (tab). - const box = gtk.Box.new(.vertical, 0); - errdefer box.unref(); - const box_widget = box.as(gtk.Widget); - box_widget.setHexpand(1); - box_widget.setVexpand(1); - self.box = box; - - // Create the initial surface since all tabs start as a single non-split - var surface = try Surface.create(window.app.core_app.alloc, window.app, .{ - .parent = parent_, - }); - errdefer surface.unref(); - surface.container = .{ .tab_ = self }; - self.elem = .{ .surface = surface }; - - // Add Surface to the Tab - self.box.append(surface.primaryWidget()); - - // Set the userdata of the box to point to this tab. - self.box.as(gobject.Object).setData(GHOSTTY_TAB, self); - window.notebook.addTab(self, "Ghostty"); - - // Attach all events - _ = gtk.Widget.signals.destroy.connect( - self.box, - *Tab, - gtkDestroy, - self, - .{}, - ); - - // We need to grab focus after Surface and Tab is added to the window. When - // creating a Tab we want to always focus on the widget. - surface.grabFocus(); -} - -/// Deinits tab by deiniting child elem. -pub fn deinit(self: *Tab, alloc: Allocator) void { - self.elem.deinit(alloc); -} - -/// Deinit and deallocate the tab. -pub fn destroy(self: *Tab, alloc: Allocator) void { - self.deinit(alloc); - alloc.destroy(self); -} - -// TODO: move this -/// Replace the surface element that this tab is showing. -pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void { - // Remove our previous widget - self.box.remove(self.elem.widget()); - - // Add our new one - self.box.append(elem.widget()); - self.elem = elem; -} - -pub fn setTitleText(self: *Tab, title: [:0]const u8) void { - self.window.notebook.setTabTitle(self, title); -} - -pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void { - self.window.notebook.setTabTooltip(self, tooltip); -} - -/// Remove this tab from the window. -pub fn remove(self: *Tab) void { - self.window.closeTab(self); -} - -/// Helper function to check if any surface in the split hierarchy needs close confirmation -fn needsConfirm(elem: Surface.Container.Elem) bool { - return switch (elem) { - .surface => |s| s.core_surface.needsConfirmQuit(), - .split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right), - }; -} - -/// Close the tab, asking for confirmation if any surface requests it. -pub fn closeWithConfirmation(tab: *Tab) void { - switch (tab.elem) { - .surface => |s| s.closeWithConfirmation( - s.core_surface.needsConfirmQuit(), - .{ .tab = tab }, - ), - .split => |s| { - if (!needsConfirm(s.top_left) and !needsConfirm(s.bottom_right)) { - tab.remove(); - return; - } - - CloseDialog.show(.{ .tab = tab }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; - }, - } -} - -fn gtkDestroy(_: *gtk.Box, self: *Tab) callconv(.c) void { - log.debug("tab box destroy", .{}); - - const alloc = self.window.app.core_app.alloc; - - // When our box is destroyed, we want to destroy our tab, too. - self.destroy(alloc); -} diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig deleted file mode 100644 index 8a4145b5f..000000000 --- a/src/apprt/gtk/TabView.zig +++ /dev/null @@ -1,284 +0,0 @@ -/// An abstraction over the Adwaita tab view to manage all the terminal tabs in -/// a window. -const TabView = @This(); - -const std = @import("std"); - -const gtk = @import("gtk"); -const adw = @import("adw"); -const gobject = @import("gobject"); -const glib = @import("glib"); - -const Window = @import("Window.zig"); -const Tab = @import("Tab.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -/// our window -window: *Window, - -/// the tab view -tab_view: *adw.TabView, - -/// Set to true so that the adw close-page handler knows we're forcing -/// and to allow a close to happen with no confirm. This is a bit of a hack -/// because we currently use GTK alerts to confirm tab close and they -/// don't carry with them the ADW state that we are confirming or not. -/// Long term we should move to ADW alerts so we can know if we are -/// confirming or not. -forcing_close: bool = false, - -pub fn init(self: *TabView, window: *Window) void { - self.* = .{ - .window = window, - .tab_view = adw.TabView.new(), - }; - self.tab_view.as(gtk.Widget).addCssClass("notebook"); - - if (adw_version.atLeast(1, 2, 0)) { - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - self.tab_view.removeShortcuts(.{ - .alt_digits = true, - .alt_zero = true, - .control_end = true, - .control_home = true, - .control_page_down = true, - .control_page_up = true, - .control_shift_end = true, - .control_shift_home = true, - .control_shift_page_down = true, - .control_shift_page_up = true, - .control_shift_tab = true, - .control_tab = true, - }); - } - - _ = adw.TabView.signals.page_attached.connect( - self.tab_view, - *TabView, - adwPageAttached, - self, - .{}, - ); - _ = adw.TabView.signals.close_page.connect( - self.tab_view, - *TabView, - adwClosePage, - self, - .{}, - ); - _ = adw.TabView.signals.create_window.connect( - self.tab_view, - *TabView, - adwTabViewCreateWindow, - self, - .{}, - ); - _ = gobject.Object.signals.notify.connect( - self.tab_view, - *TabView, - adwSelectPage, - self, - .{ - .detail = "selected-page", - }, - ); -} - -pub fn asWidget(self: *TabView) *gtk.Widget { - return self.tab_view.as(gtk.Widget); -} - -pub fn nPages(self: *TabView) c_int { - return self.tab_view.getNPages(); -} - -/// Returns the index of the currently selected page. -/// Returns null if the notebook has no pages. -fn currentPage(self: *TabView) ?c_int { - const page = self.tab_view.getSelectedPage() orelse return null; - return self.tab_view.getPagePosition(page); -} - -/// Returns the currently selected tab or null if there are none. -pub fn currentTab(self: *TabView) ?*Tab { - const page = self.tab_view.getSelectedPage() orelse return null; - const child = page.getChild().as(gobject.Object); - return @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return null)); -} - -pub fn gotoNthTab(self: *TabView, position: c_int) bool { - const page_to_select = self.tab_view.getNthPage(position); - self.tab_view.setSelectedPage(page_to_select); - return true; -} - -pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage { - return self.tab_view.getPage(tab.box.as(gtk.Widget)); -} - -pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int { - return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null); -} - -pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - // The next index is the previous or we wrap around. - const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: { - const max = self.nPages(); - break :next_idx max -| 1; - }; - - // Do nothing if we have one tab - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn gotoNextTab(self: *TabView, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - const max = self.nPages() -| 1; - const next_idx = if (page_idx < max) page_idx + 1 else 0; - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void { - const page_idx = self.getTabPosition(tab) orelse return; - - const max = self.nPages() -| 1; - var new_position: c_int = page_idx + position; - - if (new_position < 0) { - new_position = max + new_position + 1; - } else if (new_position > max) { - new_position = new_position - max - 1; - } - - if (new_position == page_idx) return; - self.reorderPage(tab, new_position); -} - -pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { - _ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position); -} - -pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const page = self.getTabPage(tab) orelse return; - page.setTitle(title.ptr); -} - -pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void { - const page = self.getTabPage(tab) orelse return; - page.setTooltip(tooltip.ptr); -} - -fn newTabInsertPosition(self: *TabView, tab: *Tab) c_int { - const numPages = self.nPages(); - return switch (tab.window.app.config.@"window-new-tab-position") { - .current => if (self.currentPage()) |page| page + 1 else numPages, - .end => numPages, - }; -} - -/// Adds a new tab with the given title to the notebook. -pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const position = self.newTabInsertPosition(tab); - const page = self.tab_view.insert(tab.box.as(gtk.Widget), position); - self.setTabTitle(tab, title); - self.tab_view.setSelectedPage(page); -} - -pub fn closeTab(self: *TabView, tab: *Tab) void { - // closeTab always expects to close unconditionally so we mark this - // as true so that the close_page call below doesn't request - // confirmation. - self.forcing_close = true; - const n = self.nPages(); - defer { - // self becomes invalid if we close the last page because we close - // the whole window - if (n > 1) self.forcing_close = false; - } - - if (self.getTabPage(tab)) |page| self.tab_view.closePage(page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // libadw versions < 1.5.1 leak the final page view - // which causes our surface to not properly cleanup. We - // unref to force the cleanup. This will trigger a critical - // warning from GTK, but I don't know any other workaround. - if (!adw_version.atLeast(1, 5, 1)) { - tab.box.unref(); - } - - self.window.close(); - } -} - -pub fn createWindow(window: *Window) !*Window { - const new_window = try Window.create(window.app.core_app.alloc, window.app); - new_window.present(); - return new_window; -} - -fn adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.c) void { - const child = page.getChild().as(gobject.Object); - const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return)); - tab.window = self.window; - - self.window.focusCurrentTab(); -} - -fn adwClosePage( - _: *adw.TabView, - page: *adw.TabPage, - self: *TabView, -) callconv(.c) c_int { - const child = page.getChild().as(gobject.Object); - const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); - self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); - if (!self.forcing_close) { - // We cannot trigger a close directly in here as the page will stay - // alive until this handler returns, breaking the assumption where - // no pages means they are all destroyed. - // - // Schedule the close request to happen in the next event cycle. - _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab); - } - - return 1; -} - -fn adwTabViewCreateWindow( - _: *adw.TabView, - self: *TabView, -) callconv(.c) ?*adw.TabView { - const window = createWindow(self.window) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.tab_view; -} - -fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.c) void { - const page = self.tab_view.getSelectedPage() orelse return; - - // If the tab was previously marked as needing attention - // (e.g. due to a bell character), we now unmark that - page.setNeedsAttention(@intFromBool(false)); - - const title = page.getTitle(); - self.window.setTitle(std.mem.span(title)); -} - -fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void { - const tab: *Tab = @ptrCast(@alignCast(data orelse return)); - tab.closeWithConfirmation(); -} diff --git a/src/apprt/gtk/URLWidget.zig b/src/apprt/gtk/URLWidget.zig deleted file mode 100644 index e59827aaf..000000000 --- a/src/apprt/gtk/URLWidget.zig +++ /dev/null @@ -1,115 +0,0 @@ -//! Represents the URL hover widgets that show the hovered URL. -//! -//! To explain a bit how this all works since its split across a few places: -//! We create a left/right pair of labels. The left label is shown by default, -//! and the right label is hidden. When the mouse enters the left label, we -//! show the right label. When the mouse leaves the left label, we hide the -//! right label. -//! -//! The hover and styling is done with a combination of GTK event controllers -//! and CSS in style.css. -const URLWidget = @This(); - -const gtk = @import("gtk"); - -/// The label that appears on the bottom left. -left: *gtk.Label, - -/// The label that appears on the bottom right. -right: *gtk.Label, - -pub fn init( - /// The overlay that we will attach our labels to. - overlay: *gtk.Overlay, - /// The URL to display. - str: [:0]const u8, -) URLWidget { - // Create the left - const left = left: { - const left = gtk.Label.new(str.ptr); - left.setEllipsize(.middle); - const widget = left.as(gtk.Widget); - widget.addCssClass("view"); - widget.addCssClass("url-overlay"); - widget.addCssClass("left"); - widget.setHalign(.start); - widget.setValign(.end); - break :left left; - }; - - // Create the right - const right = right: { - const right = gtk.Label.new(str.ptr); - right.setEllipsize(.middle); - const widget = right.as(gtk.Widget); - widget.addCssClass("hidden"); - widget.addCssClass("view"); - widget.addCssClass("url-overlay"); - widget.addCssClass("right"); - widget.setHalign(.end); - widget.setValign(.end); - break :right right; - }; - - // Setup our mouse hover event controller for the left label. - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - - left.as(gtk.Widget).addController(ec_motion.as(gtk.EventController)); - - _ = gtk.EventControllerMotion.signals.enter.connect( - ec_motion, - *gtk.Label, - gtkLeftEnter, - right, - .{}, - ); - _ = gtk.EventControllerMotion.signals.leave.connect( - ec_motion, - *gtk.Label, - gtkLeftLeave, - right, - .{}, - ); - - // Show it - overlay.addOverlay(left.as(gtk.Widget)); - overlay.addOverlay(right.as(gtk.Widget)); - - return .{ - .left = left, - .right = right, - }; -} - -/// Remove our labels from the overlay. -pub fn deinit(self: *URLWidget, overlay: *gtk.Overlay) void { - overlay.removeOverlay(self.left.as(gtk.Widget)); - overlay.removeOverlay(self.right.as(gtk.Widget)); -} - -/// Change the URL that is displayed. -pub fn setText(self: *const URLWidget, str: [:0]const u8) void { - self.left.setText(str.ptr); - self.right.setText(str.ptr); -} - -/// Callback for when the mouse enters the left label. That means that we should -/// show the right label. CSS will handle hiding the left label. -fn gtkLeftEnter( - _: *gtk.EventControllerMotion, - _: f64, - _: f64, - right: *gtk.Label, -) callconv(.c) void { - right.as(gtk.Widget).removeCssClass("hidden"); -} - -/// Callback for when the mouse leaves the left label. That means that we should -/// hide the right label. CSS will handle showing the left label. -fn gtkLeftLeave( - _: *gtk.EventControllerMotion, - right: *gtk.Label, -) callconv(.c) void { - right.as(gtk.Widget).addCssClass("hidden"); -} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig deleted file mode 100644 index 8c02396a6..000000000 --- a/src/apprt/gtk/Window.zig +++ /dev/null @@ -1,1190 +0,0 @@ -/// A Window is a single, real GTK window that holds terminal surfaces. -/// -/// A Window always contains a notebook (what GTK calls a tabbed container) -/// even while no tabs are in use, because a notebook without a tab bar has -/// no visible UI chrome. -const Window = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const adw = @import("adw"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const build_config = @import("../../build_config.zig"); -const configpkg = @import("../../config.zig"); -const font = @import("../../font/main.zig"); -const i18n = @import("../../os/main.zig").i18n; -const input = @import("../../input.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const App = @import("App.zig"); -const Builder = @import("Builder.zig"); -const Color = configpkg.Config.Color; -const Surface = @import("Surface.zig"); -const Menu = @import("menu.zig").Menu; -const Tab = @import("Tab.zig"); -const gtk_key = @import("key.zig"); -const TabView = @import("TabView.zig"); -const HeaderBar = @import("headerbar.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const CommandPalette = @import("CommandPalette.zig"); -const winprotopkg = @import("winproto.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -app: *App, - -/// Used to deduplicate updateConfig invocations -last_config: usize, - -/// Local copy of any configuration -config: DerivedConfig, - -/// Our window -window: *adw.ApplicationWindow, - -/// The header bar for the window. -headerbar: HeaderBar, - -/// The tab bar for the window. -tab_bar: *adw.TabBar, - -/// The tab overview for the window. This is possibly null since there is no -/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). -tab_overview: ?*adw.TabOverview, - -/// The notebook (tab grouping) for this window. -notebook: TabView, - -/// The "main" menu that is attached to a button in the headerbar. -titlebar_menu: Menu(Window, "titlebar_menu", true), - -/// The libadwaita widget for receiving toast send requests. -toast_overlay: *adw.ToastOverlay, - -/// The command palette. -command_palette: CommandPalette, - -/// See adwTabOverviewOpen for why we have this. -adw_tab_overview_focus_timer: ?c_uint = null, - -/// State and logic for windowing protocol for a window. -winproto: winprotopkg.Window, - -pub const DerivedConfig = struct { - background_opacity: f64, - background_blur: configpkg.Config.BackgroundBlur, - window_theme: configpkg.Config.WindowTheme, - gtk_titlebar: bool, - gtk_titlebar_hide_when_maximized: bool, - gtk_tabs_location: configpkg.Config.GtkTabsLocation, - gtk_wide_tabs: bool, - gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, - window_show_tab_bar: configpkg.Config.WindowShowTabBar, - - quick_terminal_position: configpkg.Config.QuickTerminalPosition, - quick_terminal_size: configpkg.Config.QuickTerminalSize, - quick_terminal_autohide: bool, - quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity, - - maximize: bool, - fullscreen: bool, - window_decoration: configpkg.Config.WindowDecoration, - - pub fn init(config: *const configpkg.Config) DerivedConfig { - return .{ - .background_opacity = config.@"background-opacity", - .background_blur = config.@"background-blur", - .window_theme = config.@"window-theme", - .gtk_titlebar = config.@"gtk-titlebar", - .gtk_titlebar_hide_when_maximized = config.@"gtk-titlebar-hide-when-maximized", - .gtk_tabs_location = config.@"gtk-tabs-location", - .gtk_wide_tabs = config.@"gtk-wide-tabs", - .gtk_toolbar_style = config.@"gtk-toolbar-style", - .window_show_tab_bar = config.@"window-show-tab-bar", - - .quick_terminal_position = config.@"quick-terminal-position", - .quick_terminal_size = config.@"quick-terminal-size", - .quick_terminal_autohide = config.@"quick-terminal-autohide", - .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity", - - .maximize = config.maximize, - .fullscreen = config.fullscreen, - .window_decoration = config.@"window-decoration", - }; - } -}; - -pub fn create(alloc: Allocator, app: *App) !*Window { - // Allocate a fixed pointer for our window. We try to minimize - // allocations but windows and other GUI requirements are so minimal - // compared to the steady-state terminal operation so we use heap - // allocation for this. - // - // The allocation is owned by the GtkWindow created. It will be - // freed when the window is closed. - var window = try alloc.create(Window); - errdefer alloc.destroy(window); - try window.init(app); - return window; -} - -pub fn init(self: *Window, app: *App) !void { - // Set up our own state - self.* = .{ - .app = app, - .last_config = @intFromPtr(&app.config), - .config = .init(&app.config), - .window = undefined, - .headerbar = undefined, - .tab_bar = undefined, - .tab_overview = null, - .notebook = undefined, - .titlebar_menu = undefined, - .toast_overlay = undefined, - .command_palette = undefined, - .winproto = .none, - }; - - // Create the window - self.window = .new(app.app.as(gtk.Application)); - const gtk_window = self.window.as(gtk.Window); - const gtk_widget = self.window.as(gtk.Widget); - errdefer gtk_window.destroy(); - - gtk_window.setTitle("Ghostty"); - gtk_window.setDefaultSize(1000, 600); - gtk_widget.addCssClass("window"); - gtk_widget.addCssClass("terminal-window"); - - // GTK4 grabs F10 input by default to focus the menubar icon. We want - // to disable this so that terminal programs can capture F10 (such as htop) - gtk_window.setHandleMenubarAccel(0); - gtk_window.setIconName(build_config.bundle_id); - - // Create our box which will hold our widgets in the main content area. - const box = gtk.Box.new(.vertical, 0); - - // Set up the menus - self.titlebar_menu.init(self); - - // Setup our notebook - self.notebook.init(self); - - if (adw_version.supportsDialogs()) try self.command_palette.init(self); - - // If we are using Adwaita, then we can support the tab overview. - self.tab_overview = if (adw_version.supportsTabOverview()) overview: { - const tab_overview = adw.TabOverview.new(); - tab_overview.setView(self.notebook.tab_view); - tab_overview.setEnableNewTab(1); - _ = adw.TabOverview.signals.create_tab.connect( - tab_overview, - *Window, - gtkNewTabFromOverview, - self, - .{}, - ); - _ = gobject.Object.signals.notify.connect( - tab_overview, - *Window, - adwTabOverviewOpen, - self, - .{ - .detail = "open", - }, - ); - break :overview tab_overview; - } else null; - - // gtk-titlebar can be used to disable the header bar (but keep the window - // manager's decorations). We create this no matter if we are decorated or - // not because we can have a keybind to toggle the decorations. - self.headerbar.init(self); - - { - const btn = gtk.MenuButton.new(); - btn.as(gtk.Widget).setTooltipText(i18n._("Main Menu")); - btn.as(gtk.Widget).setCanFocus(0); - btn.setIconName("open-menu-symbolic"); - btn.setPopover(self.titlebar_menu.asWidget()); - _ = gobject.Object.signals.notify.connect( - btn, - *Window, - gtkTitlebarMenuActivate, - self, - .{ - .detail = "active", - }, - ); - self.headerbar.packEnd(btn.as(gtk.Widget)); - } - - // If we're using an AdwWindow then we can support the tab overview. - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - - const btn = switch (self.config.window_show_tab_bar) { - .always, .auto => btn: { - const btn = gtk.ToggleButton.new(); - btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs")); - btn.as(gtk.Button).setIconName("view-grid-symbolic"); - _ = btn.as(gobject.Object).bindProperty( - "active", - tab_overview.as(gobject.Object), - "open", - .{ .bidirectional = true, .sync_create = true }, - ); - break :btn btn.as(gtk.Widget); - }, - .never => btn: { - const btn = adw.TabButton.new(); - btn.setView(self.notebook.tab_view); - btn.as(gtk.Actionable).setActionName("overview.open"); - break :btn btn.as(gtk.Widget); - }, - }; - - btn.setCanFocus(0); - btn.setFocusOnClick(0); - self.headerbar.packEnd(btn); - } - - { - const btn = adw.SplitButton.new(); - btn.setIconName("tab-new-symbolic"); - btn.as(gtk.Widget).setTooltipText(i18n._("New Tab")); - btn.setDropdownTooltip(i18n._("New Split")); - - var builder = Builder.init("menu-headerbar-split_menu", 1, 0); - defer builder.deinit(); - btn.setMenuModel(builder.getObject(gio.MenuModel, "menu")); - - _ = adw.SplitButton.signals.clicked.connect( - btn, - *Window, - adwNewTabClick, - self, - .{}, - ); - self.headerbar.packStart(btn.as(gtk.Widget)); - } - - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyMaximized, - self, - .{ - .detail = "maximized", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyFullscreened, - self, - .{ - .detail = "fullscreened", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyIsActive, - self, - .{ - .detail = "is-active", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowUpdateScaleFactor, - self, - .{ - .detail = "scale-factor", - }, - ); - - // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we - // need to stick the headerbar into the content box. - if (!adw_version.supportsTabOverview()) { - box.append(self.headerbar.asWidget()); - } - - // In debug we show a warning and apply the 'devel' class to the window. - // This is a really common issue where people build from source in debug and performance is really bad. - if (comptime std.debug.runtime_safety) { - const warning_box = gtk.Box.new(.vertical, 0); - const warning_text = i18n._("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); - if (adw_version.supportsBanner()) { - const banner = adw.Banner.new(warning_text); - banner.setRevealed(1); - warning_box.append(banner.as(gtk.Widget)); - } else { - const warning = gtk.Label.new(warning_text); - warning.as(gtk.Widget).setMarginTop(10); - warning.as(gtk.Widget).setMarginBottom(10); - warning_box.append(warning.as(gtk.Widget)); - } - gtk_widget.addCssClass("devel"); - warning_box.as(gtk.Widget).addCssClass("background"); - box.append(warning_box.as(gtk.Widget)); - } - - // Setup our toast overlay if we have one - self.toast_overlay = .new(); - self.toast_overlay.setChild(self.notebook.asWidget()); - box.append(self.toast_overlay.as(gtk.Widget)); - - // If we have a tab overview then we can set it on our notebook. - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - tab_overview.setView(self.notebook.tab_view); - } - - // We register a key event controller with the window so - // we can catch key events when our surface may not be - // focused (i.e. when the libadw tab overview is shown). - const ec_key_press = gtk.EventControllerKey.new(); - errdefer ec_key_press.unref(); - gtk_widget.addController(ec_key_press.as(gtk.EventController)); - - // All of our events - _ = gtk.Widget.signals.realize.connect( - self.window, - *Window, - gtkRealize, - self, - .{}, - ); - _ = gtk.Window.signals.close_request.connect( - self.window, - *Window, - gtkCloseRequest, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - self.window, - *Window, - gtkDestroy, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key_press, - *Window, - gtkKeyPressed, - self, - .{}, - ); - - // Our actions for the menu - initActions(self); - - self.tab_bar = adw.TabBar.new(); - self.tab_bar.setView(self.notebook.tab_view); - - if (adw_version.supportsToolbarView()) { - const toolbar_view = adw.ToolbarView.new(); - toolbar_view.addTopBar(self.headerbar.asWidget()); - - switch (self.config.gtk_tabs_location) { - .top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)), - .bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)), - } - toolbar_view.setContent(box.as(gtk.Widget)); - - const toolbar_style: adw.ToolbarStyle = switch (self.config.gtk_toolbar_style) { - .flat => .flat, - .raised => .raised, - .@"raised-border" => .raised_border, - }; - toolbar_view.setTopBarStyle(toolbar_style); - toolbar_view.setTopBarStyle(toolbar_style); - - // Set our application window content. - self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget)); - self.window.setContent(self.tab_overview.?.as(gtk.Widget)); - } else { - // In earlier adwaita versions, we need to add the tabbar manually since we do not use - // an AdwToolbarView. - self.tab_bar.as(gtk.Widget).addCssClass("inline"); - - switch (self.config.gtk_tabs_location) { - .top => box.insertChildAfter( - self.tab_bar.as(gtk.Widget), - self.headerbar.asWidget(), - ), - .bottom => box.append(self.tab_bar.as(gtk.Widget)), - } - } - - // If we want the window to be maximized, we do that here. - if (self.config.maximize) self.window.as(gtk.Window).maximize(); - - // If we are in fullscreen mode, new windows start fullscreen. - if (self.config.fullscreen) self.window.as(gtk.Window).fullscreen(); -} - -pub fn present(self: *Window) void { - self.window.as(gtk.Window).present(); -} - -pub fn toggleVisibility(self: *Window) void { - const widget = self.window.as(gtk.Widget); - - widget.setVisible(@intFromBool(widget.isVisible() == 0)); -} - -pub fn isQuickTerminal(self: *Window) bool { - return self.app.quick_terminal == self; -} - -pub fn updateConfig( - self: *Window, - config: *const configpkg.Config, -) !void { - // avoid multiple reconfigs when we have many surfaces contained in this - // window using the integer value of config as a simple marker to know if - // we've "seen" this particular config before - const this_config = @intFromPtr(config); - if (self.last_config == this_config) return; - self.last_config = this_config; - - self.config = .init(config); - - // We always resync our appearance whenever the config changes. - try self.syncAppearance(); - - // Update binds inside the command palette - try self.command_palette.updateConfig(config); -} - -/// Updates appearance based on config settings. Will be called once upon window -/// realization, every time the config is reloaded, and every time a window state -/// is toggled (un-/maximized, un-/fullscreened, window decorations toggled, etc.) -/// -/// TODO: Many of the initial style settings in `create` could possibly be made -/// reactive by moving them here. -pub fn syncAppearance(self: *Window) !void { - const csd_enabled = self.winproto.clientSideDecorationEnabled(); - const gtk_window = self.window.as(gtk.Window); - const gtk_widget = self.window.as(gtk.Widget); - gtk_window.setDecorated(@intFromBool(csd_enabled)); - - // Fix any artifacting that may occur in window corners. The .ssd CSS - // class is defined in the GtkWindow documentation: - // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition - // for .ssd is provided by GTK and Adwaita. - toggleCssClass(gtk_widget, "csd", csd_enabled); - toggleCssClass(gtk_widget, "ssd", !csd_enabled); - toggleCssClass(gtk_widget, "no-border-radius", !csd_enabled); - - self.headerbar.setVisible(visible: { - // Never display the header bar when CSDs are disabled. - if (!csd_enabled) break :visible false; - - // Never display the header bar as a quick terminal. - if (self.isQuickTerminal()) break :visible false; - - // Unconditionally disable the header bar when fullscreened. - if (self.window.as(gtk.Window).isFullscreen() != 0) - break :visible false; - - // *Conditionally* disable the header bar when maximized, - // and gtk-titlebar-hide-when-maximized is set - if (self.window.as(gtk.Window).isMaximized() != 0 and - self.config.gtk_titlebar_hide_when_maximized) - break :visible false; - - break :visible self.config.gtk_titlebar; - }); - - toggleCssClass( - gtk_widget, - "background", - self.config.background_opacity >= 1, - ); - - // Apply class to color headerbar if window-theme is set to `ghostty` and - // GTK version is before 4.16. The conditional is because above 4.16 - // we use GTK CSS color variables. - toggleCssClass( - gtk_widget, - "window-theme-ghostty", - !gtk_version.atLeast(4, 16, 0) and self.config.window_theme == .ghostty, - ); - - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - - // Disable the title buttons (close, maximize, minimize, ...) - // *inside* the tab overview if CSDs are disabled. - // We do spare the search button, though. - tab_overview.setShowStartTitleButtons(@intFromBool(csd_enabled)); - tab_overview.setShowEndTitleButtons(@intFromBool(csd_enabled)); - - // Update toolbar view style - toolbar_view: { - const tab_overview_child = tab_overview.getChild() orelse break :toolbar_view; - const toolbar_view = gobject.ext.cast( - adw.ToolbarView, - tab_overview_child, - ) orelse break :toolbar_view; - const toolbar_style: adw.ToolbarStyle = switch (self.config.gtk_toolbar_style) { - .flat => .flat, - .raised => .raised, - .@"raised-border" => .raised_border, - }; - toolbar_view.setTopBarStyle(toolbar_style); - toolbar_view.setBottomBarStyle(toolbar_style); - } - } - - self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs)); - self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) { - .auto, .never => @intFromBool(true), - .always => @intFromBool(false), - }); - self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) { - .always, .auto => @intFromBool(true), - .never => @intFromBool(false), - }); - - self.winproto.syncAppearance() catch |err| { - log.warn("failed to sync winproto appearance error={}", .{err}); - }; -} - -fn toggleCssClass( - widget: *gtk.Widget, - class: [:0]const u8, - v: bool, -) void { - if (v) { - widget.addCssClass(class); - } else { - widget.removeCssClass(class); - } -} - -/// Sets up the GTK actions for the window scope. Actions are how GTK handles -/// menus and such. The menu is defined in App.zig but the action is defined -/// here. The string name binds them. -fn initActions(self: *Window) void { - const window = self.window.as(gtk.ApplicationWindow); - const action_map = window.as(gio.ActionMap); - const actions = .{ - .{ "about", gtkActionAbout }, - .{ "close", gtkActionClose }, - .{ "new-window", gtkActionNewWindow }, - .{ "new-tab", gtkActionNewTab }, - .{ "close-tab", gtkActionCloseTab }, - .{ "split-right", gtkActionSplitRight }, - .{ "split-down", gtkActionSplitDown }, - .{ "split-left", gtkActionSplitLeft }, - .{ "split-up", gtkActionSplitUp }, - .{ "toggle-inspector", gtkActionToggleInspector }, - .{ "toggle-command-palette", gtkActionToggleCommandPalette }, - .{ "copy", gtkActionCopy }, - .{ "paste", gtkActionPaste }, - .{ "reset", gtkActionReset }, - .{ "clear", gtkActionClear }, - .{ "prompt-title", gtkActionPromptTitle }, - }; - - inline for (actions) |entry| { - const action = gio.SimpleAction.new(entry[0], null); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *Window, - entry[1], - self, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } -} - -pub fn deinit(self: *Window) void { - self.winproto.deinit(self.app.core_app.alloc); - if (adw_version.supportsDialogs()) self.command_palette.deinit(); - - if (self.adw_tab_overview_focus_timer) |timer| { - _ = glib.Source.remove(timer); - } -} - -/// Set the title of the window. -pub fn setTitle(self: *Window, title: [:0]const u8) void { - self.headerbar.setTitle(title); -} - -/// Set the subtitle of the window if it has one. -pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { - self.headerbar.setSubtitle(subtitle); -} - -/// Add a new tab to this window. -pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { - const alloc = self.app.core_app.alloc; - _ = try Tab.create(alloc, self, parent); - - // TODO: When this is triggered through a GTK action, the new surface - // redraws correctly. When it's triggered through keyboard shortcuts, it - // does not (cursor doesn't blink) unless reactivated by refocusing. -} - -/// Close the tab for the given notebook page. This will automatically -/// handle closing the window if there are no more tabs. -pub fn closeTab(self: *Window, tab: *Tab) void { - self.notebook.closeTab(tab); -} - -/// Go to the previous tab for a surface. -pub fn gotoPreviousTab(self: *Window, surface: *Surface) bool { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return false; - }; - if (!self.notebook.gotoPreviousTab(tab)) return false; - self.focusCurrentTab(); - return true; -} - -/// Go to the next tab for a surface. -pub fn gotoNextTab(self: *Window, surface: *Surface) bool { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return false; - }; - if (!self.notebook.gotoNextTab(tab)) return false; - self.focusCurrentTab(); - return true; -} - -/// Move the current tab for a surface. -pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return; - }; - self.notebook.moveTab(tab, position); -} - -/// Go to the last tab for a surface. -pub fn gotoLastTab(self: *Window) bool { - const max = self.notebook.nPages(); - return self.gotoTab(@intCast(max)); -} - -/// Go to the specific tab index. -pub fn gotoTab(self: *Window, n: usize) bool { - if (n == 0) return false; - const max = self.notebook.nPages(); - if (max == 0) return false; - const page_idx = std.math.cast(c_int, n - 1) orelse return false; - if (!self.notebook.gotoNthTab(@min(page_idx, max - 1))) return false; - self.focusCurrentTab(); - return true; -} - -/// Toggle tab overview (if present) -pub fn toggleTabOverview(self: *Window) void { - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - const is_open = tab_overview.getOpen() != 0; - tab_overview.setOpen(@intFromBool(!is_open)); - } -} - -/// Toggle the maximized state for this window. -pub fn toggleMaximize(self: *Window) void { - if (self.window.as(gtk.Window).isMaximized() != 0) { - self.window.as(gtk.Window).unmaximize(); - } else { - self.window.as(gtk.Window).maximize(); - } - // We update the config and call syncAppearance - // in the gtkWindowNotifyMaximized callback -} - -/// Toggle fullscreen for this window. -pub fn toggleFullscreen(self: *Window) void { - if (self.window.as(gtk.Window).isFullscreen() != 0) { - self.window.as(gtk.Window).unfullscreen(); - } else { - self.window.as(gtk.Window).fullscreen(); - } - // We update the config and call syncAppearance - // in the gtkWindowNotifyFullscreened callback -} - -/// Toggle the window decorations for this window. -pub fn toggleWindowDecorations(self: *Window) void { - self.config.window_decoration = switch (self.config.window_decoration) { - .none => switch (self.app.config.@"window-decoration") { - // If we started as none, then we switch to auto - .none => .auto, - // Switch back - .auto, .client, .server => |v| v, - }, - // Always set to none - .auto, .client, .server => .none, - }; - - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -/// Toggle the window decorations for this window. -pub fn toggleCommandPalette(self: *Window) void { - if (adw_version.supportsDialogs()) { - self.command_palette.toggle(); - } else { - log.warn("libadwaita 1.5+ is required for the command palette", .{}); - } -} - -/// Grabs focus on the currently selected tab. -pub fn focusCurrentTab(self: *Window) void { - const tab = self.notebook.currentTab() orelse return; - const surface = tab.focus_child orelse return; - _ = surface.gl_area.as(gtk.Widget).grabFocus(); - - if (surface.getTitle()) |title| { - self.setTitle(title); - } -} - -pub fn onConfigReloaded(self: *Window) void { - if (self.app.config.@"app-notifications".@"config-reload") { - self.sendToast(i18n._("Reloaded the configuration")); - } -} - -pub fn sendToast(self: *Window, title: [*:0]const u8) void { - const toast = adw.Toast.new(title); - toast.setTimeout(3); - self.toast_overlay.addToast(toast); -} - -fn gtkRealize(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { - // Initialize our window protocol logic - if (winprotopkg.Window.init( - self.app.core_app.alloc, - &self.app.winproto, - self, - )) |wp| { - self.winproto = wp; - } else |err| { - log.warn("failed to initialize window protocol error={}", .{err}); - } - - // When we are realized we always setup our appearance - self.syncAppearance() catch |err| { - log.err("failed to initialize appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyMaximized( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyFullscreened( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyIsActive( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.winproto.setUrgent(false) catch |err| { - log.err("failed to unrequest user attention={}", .{err}); - }; - - if (self.isQuickTerminal()) { - // Hide when we're unfocused - if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { - self.toggleVisibility(); - } - } -} - -fn gtkWindowUpdateScaleFactor( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - // On some platforms (namely X11) we need to refresh our appearance when - // the scale factor changes. In theory this could be more fine-grained as - // a full refresh could be expensive, but a) this *should* be rare, and - // b) quite noticeable visual bugs would occur if this is not present. - self.winproto.syncAppearance() catch |err| { - log.err( - "failed to sync appearance after scale factor has been updated={}", - .{err}, - ); - return; - }; -} - -/// Perform a binding action on the window's action surface. -pub fn performBindingAction(self: *Window, action: input.Binding.Action) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(action) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; -} - -fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -/// Create a new surface (tab or split). -fn adwNewTabClick(_: *adw.SplitButton, self: *Window) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -/// Create a new tab from the AdwTabOverview. We can't copy gtkTabNewClick -/// because we need to return an AdwTabPage from this function. -fn gtkNewTabFromOverview(_: *adw.TabOverview, self: *Window) callconv(.c) *adw.TabPage { - if (!adw_version.supportsTabOverview()) unreachable; - - const alloc = self.app.core_app.alloc; - const surface = self.actionSurface(); - const tab = Tab.create(alloc, self, surface) catch unreachable; - return self.notebook.tab_view.getPage(tab.box.as(gtk.Widget)); -} - -fn adwTabOverviewOpen( - tab_overview: *adw.TabOverview, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - if (!adw_version.supportsTabOverview()) unreachable; - - // We only care about when the tab overview is closed. - if (tab_overview.getOpen() != 0) return; - - // On tab overview close, focus is sometimes lost. This is an - // upstream issue in libadwaita[1]. When this is resolved we - // can put a runtime version check here to avoid this workaround. - // - // Our workaround is to start a timer after 500ms to refocus - // the currently selected tab. We choose 500ms because the adw - // animation is 400ms. - // - // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670 - - // If we have an old timer remove it - if (self.adw_tab_overview_focus_timer) |timer| { - _ = glib.Source.remove(timer); - } - - // Restart our timer - self.adw_tab_overview_focus_timer = glib.timeoutAdd( - 500, - adwTabOverviewFocusTimer, - self, - ); -} - -fn adwTabOverviewFocusTimer( - ud: ?*anyopaque, -) callconv(.c) c_int { - if (!adw_version.supportsTabOverview()) unreachable; - const self: *Window = @ptrCast(@alignCast(ud orelse return 0)); - self.adw_tab_overview_focus_timer = null; - self.focusCurrentTab(); - - // Remove the timer - return 0; -} - -pub fn close(self: *Window) void { - const window = self.window.as(gtk.Window); - - // Unset the quick terminal on the app level - if (self.isQuickTerminal()) self.app.quick_terminal = null; - - window.destroy(); -} - -pub fn closeWithConfirmation(self: *Window) void { - // If none of our surfaces need confirmation, we can just exit. - for (self.app.core_app.surfaces.items) |surface| { - if (surface.container.window()) |window| { - if (window == self and - surface.core_surface.needsConfirmQuit()) break; - } - } else { - self.close(); - return; - } - - CloseDialog.show(.{ .window = self }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -fn gtkCloseRequest(_: *adw.ApplicationWindow, self: *Window) callconv(.c) c_int { - log.debug("window close request", .{}); - - self.closeWithConfirmation(); - return 1; -} - -/// "destroy" signal for the window -fn gtkDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { - log.debug("window destroy", .{}); - - const alloc = self.app.core_app.alloc; - self.deinit(); - alloc.destroy(self); -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *Window, -) callconv(.c) c_int { - // We only process window-level events currently for the tab - // overview. This is primarily defensive programming because - // I'm not 100% certain how our logic below will interact with - // other parts of the application but I know for sure we must - // handle this during the tab overview. - // - // If someone can confidently show or explain that this is not - // necessary, please remove this check. - if (adw_version.supportsTabOverview()) { - if (self.tab_overview) |tab_overview| { - if (tab_overview.getOpen() == 0) return 0; - } - } - - const surface = self.app.core_app.focusedSurface() orelse return 0; - return if (surface.rt_surface.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )) 1 else 0; -} - -fn gtkActionAbout( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - const name = "Ghostty"; - const icon = "com.mitchellh.ghostty"; - const website = "https://ghostty.org"; - - if (adw_version.supportsDialogs()) { - adw.showAboutDialog( - self.window.as(gtk.Widget), - "application-name", - name, - "developer-name", - i18n._("Ghostty Developers"), - "application-icon", - icon, - "version", - build_config.version_string.ptr, - "issue-url", - "https://github.com/ghostty-org/ghostty/issues", - "website", - website, - @as(?*anyopaque, null), - ); - } else { - gtk.showAboutDialog( - self.window.as(gtk.Window), - "program-name", - name, - "logo-icon-name", - icon, - "title", - i18n._("About Ghostty"), - "version", - build_config.version_string.ptr, - "website", - website, - @as(?*anyopaque, null), - ); - } -} - -fn gtkActionClose( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.closeWithConfirmation(); -} - -fn gtkActionNewWindow( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_window = {} }); -} - -fn gtkActionNewTab( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -fn gtkActionCloseTab( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .close_tab = .this }); -} - -fn gtkActionSplitRight( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .right }); -} - -fn gtkActionSplitDown( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .down }); -} - -fn gtkActionSplitLeft( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .left }); -} - -fn gtkActionSplitUp( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .up }); -} - -fn gtkActionToggleInspector( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .inspector = .toggle }); -} - -fn gtkActionToggleCommandPalette( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.toggle_command_palette); -} - -fn gtkActionCopy( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .copy_to_clipboard = {} }); -} - -fn gtkActionPaste( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .paste_from_clipboard = {} }); -} - -fn gtkActionReset( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .reset = {} }); -} - -fn gtkActionClear( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .clear_screen = {} }); -} - -fn gtkActionPromptTitle( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .prompt_surface_title = {} }); -} - -/// Returns the surface to use for an action. -pub fn actionSurface(self: *Window) ?*CoreSurface { - const tab = self.notebook.currentTab() orelse return null; - const surface = tab.focus_child orelse return null; - return &surface.core_surface; -} - -fn gtkTitlebarMenuActivate( - btn: *gtk.MenuButton, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - // debian 12 is stuck on GTK 4.8 - if (!gtk_version.atLeast(4, 10, 0)) return; - const active = btn.getActive() != 0; - if (active) { - self.titlebar_menu.refresh(); - } else { - self.focusCurrentTab(); - } -} diff --git a/src/apprt/gtk/blueprint_compiler.zig b/src/apprt/gtk/blueprint_compiler.zig deleted file mode 100644 index 9bc515655..000000000 --- a/src/apprt/gtk/blueprint_compiler.zig +++ /dev/null @@ -1,160 +0,0 @@ -const std = @import("std"); - -pub const c = @cImport({ - @cInclude("adwaita.h"); -}); - -const adwaita_version = std.SemanticVersion{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, -}; -const required_blueprint_version = std.SemanticVersion{ - .major = 0, - .minor = 16, - .patch = 0, -}; - -pub fn main() !void { - var debug_allocator: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug_allocator.deinit(); - const alloc = debug_allocator.allocator(); - - var it = try std.process.argsWithAllocator(alloc); - defer it.deinit(); - - _ = it.next(); - - const required_adwaita_version = std.SemanticVersion{ - .major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10), - .minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10), - .patch = 0, - }; - const output = it.next() orelse return error.NoOutput; - const input = it.next() orelse return error.NoInput; - - if (adwaita_version.order(required_adwaita_version) == .lt) { - std.debug.print( - \\`libadwaita` is too old. - \\ - \\Ghostty requires a version {} or newer of `libadwaita` to - \\compile this blueprint. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - , .{required_adwaita_version}); - std.posix.exit(1); - } - - { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ - "blueprint-compiler", - "--version", - }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { - error.FileNotFound => { - std.debug.print( - \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - }, - else => return err, - }; - switch (term) { - .Exited => |rc| { - if (rc != 0) std.process.exit(1); - }, - else => std.process.exit(1), - } - - const version = try std.SemanticVersion.parse(std.mem.trim(u8, stdout.items, &std.ascii.whitespace)); - if (version.order(required_blueprint_version) == .lt) { - std.debug.print( - \\`blueprint-compiler` is the wrong version. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - } - } - - { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ - "blueprint-compiler", - "compile", - "--output", - output, - input, - }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { - error.FileNotFound => { - std.debug.print( - \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - }, - else => return err, - }; - - switch (term) { - .Exited => |rc| { - if (rc != 0) { - std.debug.print("{s}", .{stderr.items}); - std.process.exit(1); - } - }, - else => { - std.debug.print("{s}", .{stderr.items}); - std.process.exit(1); - }, - } - } -} diff --git a/src/apprt/gtk-ng/build/blueprint.zig b/src/apprt/gtk/build/blueprint.zig similarity index 100% rename from src/apprt/gtk-ng/build/blueprint.zig rename to src/apprt/gtk/build/blueprint.zig diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk/build/gresource.zig similarity index 98% rename from src/apprt/gtk-ng/build/gresource.zig rename to src/apprt/gtk/build/gresource.zig index 3cd385483..1f253fd5e 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -14,10 +14,10 @@ pub const app_id = "com.mitchellh.ghostty"; /// The path to the Blueprint files. The folder structure is expected to be /// `{version}/{name}.blp` where `version` is the major and minor /// minimum adwaita version. -pub const ui_path = "src/apprt/gtk-ng/ui"; +pub const ui_path = "src/apprt/gtk/ui"; /// The path to the CSS files. -pub const css_path = "src/apprt/gtk-ng/css"; +pub const css_path = "src/apprt/gtk/css"; /// The possible icon sizes we'll embed into the gresource file. /// If any size doesn't exist then it will be an error. We could diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig index 2f5104d09..23c4d545e 100644 --- a/src/apprt/gtk/cgroup.zig +++ b/src/apprt/gtk/cgroup.zig @@ -2,23 +2,34 @@ /// each individual surface into its own cgroup. const std = @import("std"); const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); -const Allocator = std.mem.Allocator; const App = @import("App.zig"); const internal_os = @import("../../os/main.zig"); const log = std.log.scoped(.gtk_systemd_cgroup); +pub const Options = struct { + memory_high: ?u64 = null, + pids_max: ?u64 = null, +}; + /// Initialize the cgroup for the app. This will create our /// transient scope, initialize the cgroups we use for the app, /// configure them, and return the cgroup path for the app. -pub fn init(app: *App) ![]const u8 { +/// +/// Returns the path of the current cgroup for the app, which is +/// allocated with the given allocator. +pub fn init( + alloc: Allocator, + dbus: *gio.DBusConnection, + opts: Options, +) ![]const u8 { const pid = std.os.linux.getpid(); - const alloc = app.core_app.alloc; // Get our initial cgroup. We need this so we can compare // and detect when we've switched to our transient group. @@ -31,7 +42,7 @@ pub fn init(app: *App) ![]const u8 { // Create our transient scope. If this succeeds then the unit // was created, but we may not have moved into it yet, so we need // to do a dumb busy loop to wait for the move to complete. - try createScope(app, pid); + try createScope(dbus, pid); const transient = transient: while (true) { const current = try internal_os.cgroup.current( alloc, @@ -67,7 +78,7 @@ pub fn init(app: *App) ![]const u8 { // of "max" because it's a soft limit that can be exceeded and // can be monitored by things like systemd-oomd to kill if needed, // versus an instant hard kill. - if (app.config.@"linux-cgroup-memory-limit") |limit| { + if (opts.memory_high) |limit| { try internal_os.cgroup.configureLimit(surfaces, .{ .memory_high = limit, }); @@ -75,7 +86,7 @@ pub fn init(app: *App) ![]const u8 { // Configure the "max" pids limit. This is a hard limit and cannot be // exceeded. - if (app.config.@"linux-cgroup-processes-limit") |limit| { + if (opts.pids_max) |limit| { try internal_os.cgroup.configureLimit(surfaces, .{ .pids_max = limit, }); @@ -108,15 +119,12 @@ fn enableControllers(alloc: Allocator, cgroup: []const u8) !void { ); } -/// Create a transient systemd scope unit for the current process. -/// -/// On success this will return the name of the transient scope -/// cgroup prefix, allocated with the given allocator. -fn createScope(app: *App, pid_: std.os.linux.pid_t) !void { - const gio_app = app.app.as(gio.Application); - const connection = gio_app.getDbusConnection() orelse - return error.DbusConnectionRequired; - +/// Create a transient systemd scope unit for the current process and +/// move our process into it. +fn createScope( + dbus: *gio.DBusConnection, + pid_: std.os.linux.pid_t, +) !void { const pid: u32 = @intCast(pid_); // The unit name needs to be unique. We use the pid for this. @@ -180,7 +188,7 @@ fn createScope(app: *App, pid_: std.os.linux.pid_t) !void { const value = builder.end(); - const reply = connection.callSync( + const reply = dbus.callSync( "org.freedesktop.systemd1", "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager", diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk/class.zig similarity index 100% rename from src/apprt/gtk-ng/class.zig rename to src/apprt/gtk/class.zig diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk/class/application.zig similarity index 99% rename from src/apprt/gtk-ng/class/application.zig rename to src/apprt/gtk/class/application.zig index f0fda2680..5f87613cd 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -223,10 +223,8 @@ pub const Application = extern struct { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => switch (config.@"launched-from".?) { - .desktop, .systemd, .dbus => true, - .cli => false, - }, + // This should have been resolved to true/false during config loading. + .detect => unreachable, }; // Setup the flags for our application. @@ -418,9 +416,7 @@ pub const Application = extern struct { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. + // a window). // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 const priv = self.private(); @@ -428,15 +424,11 @@ pub const Application = extern struct { // We need to scope any config access because once we run our // event loop, this can change out from underneath us. const config = priv.config.get(); - if (config.@"initial-window") switch (config.@"launched-from".?) { - .desktop, .cli => self.as(gio.Application).activate(), - .dbus, .systemd => {}, - }; + if (config.@"initial-window") self.as(gio.Application).activate(); } // If we are NOT the primary instance, then we never want to run. - // This means that another instance of the GTK app is running and - // our "activate" call above will open a window. + // This means that another instance of the GTK app is running. if (self.as(gio.Application).getIsRemote() != 0) { log.debug( "application is remote, exiting run loop after activation", @@ -476,7 +468,14 @@ pub const Application = extern struct { break :q false; }; - if (must_quit) self.quit(); + if (must_quit) { + // All must quit scenarios do not need confirmation. + // Furthermore, must quit scenarios may result in a situation + // where its unsafe to even access the app/surface memory + // since its in the process of being freed. We must simply + // begin our exit immediately. + self.quitNow(); + } } } diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig rename to src/apprt/gtk/class/clipboard_confirmation_dialog.zig diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk/class/close_confirmation_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/close_confirmation_dialog.zig rename to src/apprt/gtk/class/close_confirmation_dialog.zig diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig similarity index 100% rename from src/apprt/gtk-ng/class/command_palette.zig rename to src/apprt/gtk/class/command_palette.zig diff --git a/src/apprt/gtk-ng/class/config.zig b/src/apprt/gtk/class/config.zig similarity index 100% rename from src/apprt/gtk-ng/class/config.zig rename to src/apprt/gtk/class/config.zig diff --git a/src/apprt/gtk-ng/class/config_errors_dialog.zig b/src/apprt/gtk/class/config_errors_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/config_errors_dialog.zig rename to src/apprt/gtk/class/config_errors_dialog.zig diff --git a/src/apprt/gtk-ng/class/debug_warning.zig b/src/apprt/gtk/class/debug_warning.zig similarity index 100% rename from src/apprt/gtk-ng/class/debug_warning.zig rename to src/apprt/gtk/class/debug_warning.zig diff --git a/src/apprt/gtk-ng/class/dialog.zig b/src/apprt/gtk/class/dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/dialog.zig rename to src/apprt/gtk/class/dialog.zig diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig similarity index 100% rename from src/apprt/gtk-ng/class/global_shortcuts.zig rename to src/apprt/gtk/class/global_shortcuts.zig diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig similarity index 100% rename from src/apprt/gtk-ng/class/imgui_widget.zig rename to src/apprt/gtk/class/imgui_widget.zig diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig similarity index 100% rename from src/apprt/gtk-ng/class/inspector_widget.zig rename to src/apprt/gtk/class/inspector_widget.zig diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk/class/inspector_window.zig similarity index 100% rename from src/apprt/gtk-ng/class/inspector_window.zig rename to src/apprt/gtk/class/inspector_window.zig diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk/class/resize_overlay.zig similarity index 100% rename from src/apprt/gtk-ng/class/resize_overlay.zig rename to src/apprt/gtk/class/resize_overlay.zig diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig similarity index 100% rename from src/apprt/gtk-ng/class/split_tree.zig rename to src/apprt/gtk/class/split_tree.zig diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk/class/surface.zig similarity index 98% rename from src/apprt/gtk-ng/class/surface.zig rename to src/apprt/gtk/class/surface.zig index 25ee1f94f..c26d0c1ef 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -496,6 +496,9 @@ pub const Surface = extern struct { /// if this is true, then it means the terminal is non-functional. @"error": bool = false, + /// The source that handles setting our child property. + idle_rechild: ?c_uint = null, + /// A weak reference to an inspector window. inspector: ?*InspectorWindow = null, @@ -504,6 +507,8 @@ pub const Surface = extern struct { context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, progress_bar_overlay: *gtk.ProgressBar, + error_page: *adw.StatusPage, + terminal_page: *gtk.Overlay, pub var offset: c_int = 0; }; @@ -595,17 +600,6 @@ pub const Surface = extern struct { return @intFromBool(config.@"bell-features".border); } - fn closureStackChildName( - _: *Self, - error_: c_int, - ) callconv(.c) ?[*:0]const u8 { - const err = error_ != 0; - return if (err) - glib.ext.dupeZ(u8, "error") - else - glib.ext.dupeZ(u8, "terminal"); - } - pub fn toggleFullscreen(self: *Self) void { signals.@"toggle-fullscreen".impl.emit( self, @@ -1370,6 +1364,19 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.idle_rechild) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle_rechild = null; + } + + // This works around a GTK double-free bug where if you bind + // to a top-level template child, it frees twice if the widget is + // also the root child of the template. By unsetting the child here, + // we avoid the double-free. + self.as(adw.Bin).setChild(null); + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -1651,8 +1658,26 @@ pub const Surface = extern struct { self.as(gtk.Widget).removeCssClass("background"); } - // Note above: in both cases setting our error view is handled by - // a Gtk.Stack visible-child-name binding. + // We need to set our child property on an idle tick, because the + // error property can be triggered by signals that are in the middle + // of widget mapping and changing our child during that time + // results in a hard gtk crash. + if (priv.idle_rechild == null) priv.idle_rechild = glib.idleAdd( + onIdleRechild, + self, + ); + } + + fn onIdleRechild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.idle_rechild = null; + if (priv.@"error") { + self.as(adw.Bin).setChild(priv.error_page.as(gtk.Widget)); + } else { + self.as(adw.Bin).setChild(priv.terminal_page.as(gtk.Widget)); + } + return 0; } fn propMouseHoverUrl( @@ -2699,8 +2724,10 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("child_exited_overlay", .{}); class.bindTemplateChildPrivate("context_menu", .{}); + class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); @@ -2736,7 +2763,6 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); - class.bindTemplateCallback("stack_child_name", &closureStackChildName); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig similarity index 100% rename from src/apprt/gtk-ng/class/surface_child_exited.zig rename to src/apprt/gtk/class/surface_child_exited.zig diff --git a/src/apprt/gtk-ng/class/surface_title_dialog.zig b/src/apprt/gtk/class/surface_title_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/surface_title_dialog.zig rename to src/apprt/gtk/class/surface_title_dialog.zig diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk/class/tab.zig similarity index 100% rename from src/apprt/gtk-ng/class/tab.zig rename to src/apprt/gtk/class/tab.zig diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk/class/window.zig similarity index 99% rename from src/apprt/gtk-ng/class/window.zig rename to src/apprt/gtk/class/window.zig index 862455fc8..df6ea647f 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -306,6 +306,13 @@ pub const Window = extern struct { const config = config_obj.get(); if (config.maximize) self.as(gtk.Window).maximize(); if (config.fullscreen) self.as(gtk.Window).fullscreen(); + + // If we have an explicit title set, we set that immediately + // so that any applications inspecting the window states see + // an immediate title set when the window appears, rather than + // waiting possibly a few event loop ticks for it to sync from + // the surface. + if (config.title) |v| self.as(gtk.Window).setTitle(v); } // We always sync our appearance at the end because loading our diff --git a/src/apprt/gtk-ng/css/style-dark.css b/src/apprt/gtk/css/style-dark.css similarity index 100% rename from src/apprt/gtk-ng/css/style-dark.css rename to src/apprt/gtk/css/style-dark.css diff --git a/src/apprt/gtk-ng/css/style-hc-dark.css b/src/apprt/gtk/css/style-hc-dark.css similarity index 100% rename from src/apprt/gtk-ng/css/style-hc-dark.css rename to src/apprt/gtk/css/style-hc-dark.css diff --git a/src/apprt/gtk-ng/css/style-hc.css b/src/apprt/gtk/css/style-hc.css similarity index 100% rename from src/apprt/gtk-ng/css/style-hc.css rename to src/apprt/gtk/css/style-hc.css diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk/css/style.css similarity index 100% rename from src/apprt/gtk-ng/css/style.css rename to src/apprt/gtk/css/style.css diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk/ext.zig similarity index 100% rename from src/apprt/gtk-ng/ext.zig rename to src/apprt/gtk/ext.zig diff --git a/src/apprt/gtk-ng/ext/actions.zig b/src/apprt/gtk/ext/actions.zig similarity index 100% rename from src/apprt/gtk-ng/ext/actions.zig rename to src/apprt/gtk/ext/actions.zig diff --git a/src/apprt/gtk/flatpak.zig b/src/apprt/gtk/flatpak.zig deleted file mode 100644 index dc47c671b..000000000 --- a/src/apprt/gtk/flatpak.zig +++ /dev/null @@ -1,29 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const build_config = @import("../../build_config.zig"); -const internal_os = @import("../../os/main.zig"); -const glib = @import("glib"); - -pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir { - if (comptime build_config.flatpak) { - // Only consult Flatpak runtime data for host case. - if (internal_os.isFlatpak()) { - var result: internal_os.ResourcesDir = .{ - .app_path = try alloc.dupe(u8, "/app/share/ghostty"), - }; - errdefer alloc.free(result.app_path.?); - - const keyfile = glib.KeyFile.new(); - defer keyfile.unref(); - - if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result; - const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result; - defer glib.free(app_dir.ptr); - - result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" }); - return result; - } - } - - return try internal_os.resourcesDir(alloc); -} diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig deleted file mode 100644 index 4a2e42085..000000000 --- a/src/apprt/gtk/gresource.zig +++ /dev/null @@ -1,168 +0,0 @@ -const std = @import("std"); - -const css_files = [_][]const u8{ - "style.css", - "style-dark.css", - "style-hc.css", - "style-hc-dark.css", -}; - -const icons = [_]struct { - alias: []const u8, - source: []const u8, -}{ - .{ - .alias = "16x16", - .source = "16", - }, - .{ - .alias = "16x16@2", - .source = "32", - }, - .{ - .alias = "32x32", - .source = "32", - }, - .{ - .alias = "32x32@2", - .source = "64", - }, - .{ - .alias = "128x128", - .source = "128", - }, - .{ - .alias = "128x128@2", - .source = "256", - }, - .{ - .alias = "256x256", - .source = "256", - }, - .{ - .alias = "256x256@2", - .source = "512", - }, - .{ - .alias = "512x512", - .source = "512", - }, - .{ - .alias = "1024x1024", - .source = "1024", - }, -}; - -pub const VersionedBlueprint = struct { - major: u16, - minor: u16, - name: []const u8, -}; - -pub const blueprint_files = [_]VersionedBlueprint{ - .{ .major = 1, .minor = 5, .name = "prompt-title-dialog" }, - .{ .major = 1, .minor = 5, .name = "config-errors-dialog" }, - .{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" }, - .{ .major = 1, .minor = 5, .name = "command-palette" }, - .{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" }, - .{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" }, - .{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" }, - .{ .major = 1, .minor = 5, .name = "ccw-osc-52-write" }, - .{ .major = 1, .minor = 5, .name = "ccw-paste" }, - .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, - .{ .major = 1, .minor = 2, .name = "ccw-osc-52-read" }, - .{ .major = 1, .minor = 2, .name = "ccw-osc-52-write" }, - .{ .major = 1, .minor = 2, .name = "ccw-paste" }, -}; - -pub fn main() !void { - var debug_allocator: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug_allocator.deinit(); - const alloc = debug_allocator.allocator(); - - var extra_ui_files: std.ArrayListUnmanaged([]const u8) = .empty; - defer { - for (extra_ui_files.items) |item| alloc.free(item); - extra_ui_files.deinit(alloc); - } - - var it = try std.process.argsWithAllocator(alloc); - defer it.deinit(); - - while (it.next()) |argument| { - if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) { - try extra_ui_files.append(alloc, try alloc.dupe(u8, argument)); - } - } - - const writer = std.io.getStdOut().writer(); - - try writer.writeAll( - \\ - \\ - \\ - \\ - ); - for (css_files) |css_file| { - try writer.print( - " src/apprt/gtk/{s}\n", - .{ css_file, css_file }, - ); - } - try writer.writeAll( - \\ - \\ - \\ - ); - for (icons) |icon| { - try writer.print( - " images/gnome/{s}.png\n", - .{ icon.alias, icon.source }, - ); - } - try writer.writeAll( - \\ - \\ - \\ - ); - for (extra_ui_files.items) |ui_file| { - for (blueprint_files) |file| { - const expected = try std.fmt.allocPrint(alloc, "/{d}.{d}/{s}.ui", .{ file.major, file.minor, file.name }); - defer alloc.free(expected); - if (!std.mem.endsWith(u8, ui_file, expected)) continue; - try writer.print( - " {s}\n", - .{ file.major, file.minor, file.name, ui_file }, - ); - break; - } else return error.BlueprintNotFound; - } - try writer.writeAll( - \\ - \\ - \\ - ); -} - -pub const dependencies = deps: { - const total = css_files.len + icons.len + blueprint_files.len; - var deps: [total][]const u8 = undefined; - var index: usize = 0; - for (css_files) |css_file| { - deps[index] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file}); - index += 1; - } - for (icons) |icon| { - deps[index] = std.fmt.comptimePrint("images/gnome/{s}.png", .{icon.source}); - index += 1; - } - for (blueprint_files) |blueprint_file| { - deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{d}.{d}/{s}.blp", .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }); - index += 1; - } - break :deps deps; -}; diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig deleted file mode 100644 index 03c1b427b..000000000 --- a/src/apprt/gtk/headerbar.zig +++ /dev/null @@ -1,54 +0,0 @@ -const HeaderBar = @This(); - -const std = @import("std"); - -const adw = @import("adw"); -const gtk = @import("gtk"); - -const Window = @import("Window.zig"); - -/// the Adwaita headerbar widget -headerbar: *adw.HeaderBar, - -/// the Window that we belong to -window: *Window, - -/// the Adwaita window title widget -title: *adw.WindowTitle, - -pub fn init(self: *HeaderBar, window: *Window) void { - self.* = .{ - .headerbar = adw.HeaderBar.new(), - .window = window, - .title = adw.WindowTitle.new( - window.window.as(gtk.Window).getTitle() orelse "Ghostty", - "", - ), - }; - self.headerbar.setTitleWidget(self.title.as(gtk.Widget)); -} - -pub fn setVisible(self: *const HeaderBar, visible: bool) void { - self.headerbar.as(gtk.Widget).setVisible(@intFromBool(visible)); -} - -pub fn asWidget(self: *const HeaderBar) *gtk.Widget { - return self.headerbar.as(gtk.Widget); -} - -pub fn packEnd(self: *const HeaderBar, widget: *gtk.Widget) void { - self.headerbar.packEnd(widget); -} - -pub fn packStart(self: *const HeaderBar, widget: *gtk.Widget) void { - self.headerbar.packStart(widget); -} - -pub fn setTitle(self: *const HeaderBar, title: [:0]const u8) void { - self.window.window.as(gtk.Window).setTitle(title); - self.title.setTitle(title); -} - -pub fn setSubtitle(self: *const HeaderBar, subtitle: [:0]const u8) void { - self.title.setSubtitle(subtitle); -} diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig deleted file mode 100644 index 3adeb9711..000000000 --- a/src/apprt/gtk/inspector.zig +++ /dev/null @@ -1,184 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gtk = @import("gtk"); - -const build_config = @import("../../build_config.zig"); -const i18n = @import("../../os/main.zig").i18n; -const App = @import("App.zig"); -const Surface = @import("Surface.zig"); -const TerminalWindow = @import("Window.zig"); -const ImguiWidget = @import("ImguiWidget.zig"); -const CoreInspector = @import("../../inspector/main.zig").Inspector; - -const log = std.log.scoped(.inspector); - -/// Inspector is the primary stateful object that represents a terminal -/// inspector. An inspector is 1:1 with a Surface and is owned by a Surface. -/// Closing a surface must close its inspector. -pub const Inspector = struct { - /// The surface that owns this inspector. - surface: *Surface, - - /// The current state of where this inspector is rendered. The Inspector - /// is the state of the inspector but this is the state of the GUI. - location: LocationState, - - /// This is true if we want to destroy this inspector as soon as the - /// location is closed. For example: set this to true, request the - /// window be closed, let GTK do its cleanup, then note this to destroy - /// the inner state. - destroy_on_close: bool = true, - - /// Location where the inspector will be launched. - pub const Location = union(LocationKey) { - hidden: void, - window: void, - }; - - /// The internal state for each possible location. - const LocationState = union(LocationKey) { - hidden: void, - window: Window, - }; - - const LocationKey = enum { - /// No GUI, but load the inspector state. - hidden, - - /// A dedicated window for the inspector. - window, - }; - - /// Create an inspector for the given surface in the given location. - pub fn create(surface: *Surface, location: Location) !*Inspector { - const alloc = surface.app.core_app.alloc; - var ptr = try alloc.create(Inspector); - errdefer alloc.destroy(ptr); - try ptr.init(surface, location); - return ptr; - } - - /// Destroy all memory associated with this inspector. You generally - /// should NOT call this publicly and should call `close` instead to - /// use the GTK lifecycle. - pub fn destroy(self: *Inspector) void { - assert(self.location == .hidden); - const alloc = self.allocator(); - self.surface.inspector = null; - self.deinit(); - alloc.destroy(self); - } - - fn init(self: *Inspector, surface: *Surface, location: Location) !void { - self.* = .{ - .surface = surface, - .location = undefined, - }; - - // Activate the inspector. If it doesn't work we ignore the error - // because we can just show an error in the inspector window. - self.surface.core_surface.activateInspector() catch |err| { - log.err("failed to activate inspector err={}", .{err}); - }; - - switch (location) { - .hidden => self.location = .{ .hidden = {} }, - .window => try self.initWindow(), - } - } - - fn deinit(self: *Inspector) void { - self.surface.core_surface.deactivateInspector(); - } - - /// Request the inspector is closed. - pub fn close(self: *Inspector) void { - switch (self.location) { - .hidden => self.locationDidClose(), - .window => |v| v.close(), - } - } - - fn locationDidClose(self: *Inspector) void { - self.location = .{ .hidden = {} }; - if (self.destroy_on_close) self.destroy(); - } - - pub fn queueRender(self: *const Inspector) void { - switch (self.location) { - .hidden => {}, - .window => |v| v.imgui_widget.queueRender(), - } - } - - fn allocator(self: *const Inspector) Allocator { - return self.surface.app.core_app.alloc; - } - - fn initWindow(self: *Inspector) !void { - self.location = .{ .window = undefined }; - try self.location.window.init(self); - } -}; - -/// A dedicated window to hold an inspector instance. -const Window = struct { - inspector: *Inspector, - window: *gtk.ApplicationWindow, - imgui_widget: ImguiWidget, - - pub fn init(self: *Window, inspector: *Inspector) !void { - // Initialize to undefined - self.* = .{ - .inspector = inspector, - .window = undefined, - .imgui_widget = undefined, - }; - - // Create the window - self.window = .new(inspector.surface.app.app.as(gtk.Application)); - errdefer self.window.as(gtk.Window).destroy(); - - self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector")); - self.window.as(gtk.Window).setDefaultSize(1000, 600); - self.window.as(gtk.Window).setIconName(build_config.bundle_id); - self.window.as(gtk.Widget).addCssClass("window"); - self.window.as(gtk.Widget).addCssClass("inspector-window"); - - // Initialize our imgui widget - try self.imgui_widget.init(); - errdefer self.imgui_widget.deinit(); - self.imgui_widget.render_callback = &imguiRender; - self.imgui_widget.render_userdata = self; - CoreInspector.setup(); - - // Signals - _ = gtk.Widget.signals.destroy.connect(self.window, *Window, gtkDestroy, self, .{}); - // Show the window - self.window.as(gtk.Window).setChild(self.imgui_widget.gl_area.as(gtk.Widget)); - self.window.as(gtk.Window).present(); - } - - pub fn deinit(self: *Window) void { - self.inspector.locationDidClose(); - } - - pub fn close(self: *const Window) void { - self.window.as(gtk.Window).destroy(); - } - - fn imguiRender(ud: ?*anyopaque) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); - const surface = &self.inspector.surface.core_surface; - const inspector = surface.inspector orelse return; - inspector.render(); - } - - /// "destroy" signal for the window - fn gtkDestroy(_: *gtk.ApplicationWindow, self: *Window) callconv(.c) void { - log.debug("window destroy", .{}); - self.deinit(); - } -}; diff --git a/src/apprt/gtk/ipc.zig b/src/apprt/gtk/ipc.zig deleted file mode 100644 index 7c2dc3887..000000000 --- a/src/apprt/gtk/ipc.zig +++ /dev/null @@ -1 +0,0 @@ -pub const openNewWindow = @import("ipc/new_window.zig").openNewWindow; diff --git a/src/apprt/gtk-ng/ipc/DBus.zig b/src/apprt/gtk/ipc/DBus.zig similarity index 100% rename from src/apprt/gtk-ng/ipc/DBus.zig rename to src/apprt/gtk/ipc/DBus.zig diff --git a/src/apprt/gtk/ipc/new_window.zig b/src/apprt/gtk/ipc/new_window.zig index 1c29ebd3f..55e2e0e01 100644 --- a/src/apprt/gtk/ipc/new_window.zig +++ b/src/apprt/gtk/ipc/new_window.zig @@ -1,10 +1,10 @@ const std = @import("std"); -const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const gio = @import("gio"); const glib = @import("glib"); + const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); // Use a D-Bus method call to open a new window on GTK. // See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI @@ -20,153 +20,43 @@ const apprt = @import("../../../apprt.zig"); // ``` // gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] // ``` -pub fn openNewWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { - const stderr = std.io.getStdErr().writer(); +pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { + var dbus = try DBus.init( + alloc, + target, + if (value.arguments == null) + "new-window" + else + "new-window-command", + ); + defer dbus.deinit(alloc); - // Get the appropriate bus name and object path for contacting the - // Ghostty instance we're interested in. - const bus_name: [:0]const u8, const object_path: [:0]const u8 = switch (target) { - .class => |class| result: { - // Force the usage of the class specified on the CLI to determine the - // bus name and object path. - const object_path = try std.fmt.allocPrintZ(alloc, "/{s}", .{class}); + if (value.arguments) |arguments| { + // If `-e` was specified on the command line, the first + // parameter is an array of strings that contain the arguments + // that came after `-e`, which will be interpreted as a command + // to run. + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); - std.mem.replaceScalar(u8, object_path, '.', '/'); - std.mem.replaceScalar(u8, object_path, '-', '_'); + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); - break :result .{ class, object_path }; - }, - .detect => switch (builtin.mode) { - .Debug, .ReleaseSafe => .{ "com.mitchellh.ghostty-debug", "/com/mitchellh/ghostty_debug" }, - .ReleaseFast, .ReleaseSmall => .{ "com.mitchellh.ghostty", "/com/mitchellh/ghostty" }, - }, - }; - defer { - switch (target) { - .class => alloc.free(object_path), - .detect => {}, + var command: glib.VariantBuilder = undefined; + command.init(as_variant_type); + errdefer command.clear(); + + for (arguments) |argument| { + const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); + defer bytes.unref(); + const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + command.addValue(string); } + + dbus.addParameter(command.end()); } - if (gio.Application.idIsValid(bus_name.ptr) == 0) { - try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name}); - return error.IPCFailed; - } - - if (glib.Variant.isObjectPath(object_path.ptr) == 0) { - try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path}); - return error.IPCFailed; - } - - const dbus = dbus: { - var err_: ?*glib.Error = null; - defer if (err_) |err| err.free(); - - const dbus_ = gio.busGetSync(.session, null, &err_); - if (err_) |err| { - try stderr.print( - "Unable to establish connection to D-Bus session bus: {s}\n", - .{err.f_message orelse "(unknown)"}, - ); - return error.IPCFailed; - } - - break :dbus dbus_ orelse { - try stderr.print("gio.busGetSync returned null\n", .{}); - return error.IPCFailed; - }; - }; - defer dbus.unref(); - - // use a builder to create the D-Bus method call payload - const payload = payload: { - const builder_type = glib.VariantType.new("(sava{sv})"); - defer glib.free(builder_type); - - // Initialize our builder to build up our parameters - var builder: glib.VariantBuilder = undefined; - builder.init(builder_type); - errdefer builder.clear(); - - // action - if (value.arguments == null) { - builder.add("s", "new-window"); - } else { - builder.add("s", "new-window-command"); - } - - // parameters - { - const av = glib.VariantType.new("av"); - defer av.free(); - - var parameters: glib.VariantBuilder = undefined; - parameters.init(av); - errdefer parameters.clear(); - - if (value.arguments) |arguments| { - // If `-e` was specified on the command line, the first - // parameter is an array of strings that contain the arguments - // that came after `-e`, which will be interpreted as a command - // to run. - { - const as = glib.VariantType.new("as"); - defer as.free(); - - var command: glib.VariantBuilder = undefined; - command.init(as); - errdefer command.clear(); - - for (arguments) |argument| { - command.add("s", argument.ptr); - } - - parameters.add("v", command.end()); - } - } - - builder.addValue(parameters.end()); - } - - { - const platform_data = glib.VariantType.new("a{sv}"); - defer platform_data.free(); - - builder.open(platform_data); - defer builder.close(); - - // we have no platform data - } - - break :payload builder.end(); - }; - - { - var err_: ?*glib.Error = null; - defer if (err_) |err| err.free(); - - const result_ = dbus.callSync( - bus_name, - object_path, - "org.gtk.Actions", - "Activate", - payload, - null, // We don't care about the return type, we don't do anything with it. - .{}, // no flags - -1, // default timeout - null, // not cancellable - &err_, - ); - defer if (result_) |result| result.unref(); - - if (err_) |err| { - try stderr.print( - "D-Bus method call returned an error err={s}\n", - .{err.f_message orelse "(unknown)"}, - ); - return error.IPCFailed; - } - } + try dbus.send(); return true; } diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index fc3296366..a00b0312e 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -9,7 +9,10 @@ const input = @import("../../input.zig"); const winproto = @import("winproto.zig"); /// Returns a GTK accelerator string from a trigger. -pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { +pub fn accelFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); @@ -30,7 +33,10 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u /// Returns a XDG-compliant shortcuts string from a trigger. /// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ -pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { +pub fn xdgShortcutFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); @@ -54,7 +60,7 @@ pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]c return slice[0 .. slice.len - 1 :0]; } -fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool { switch (trigger.key) { .physical => |k| { const keyval = keyvalFromKey(k) orelse return false; diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig deleted file mode 100644 index 50d0d1227..000000000 --- a/src/apprt/gtk/menu.zig +++ /dev/null @@ -1,139 +0,0 @@ -const std = @import("std"); - -const gtk = @import("gtk"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const gobject = @import("gobject"); - -const apprt = @import("../../apprt.zig"); -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Surface = @import("Surface.zig"); -const Builder = @import("Builder.zig"); - -/// Abstract GTK menus to take advantage of machinery for buildtime/comptime -/// error checking. -pub fn Menu( - /// GTK apprt type that the menu is "for". Window and Surface are supported - /// right now. - comptime T: type, - /// Name of the menu. Along with the apprt type, this is used to look up the - /// builder ui definitions of the menu. - comptime menu_name: []const u8, - /// Should the popup have a pointer pointing to the location that it's - /// attached to. - comptime arrow: bool, -) type { - return struct { - const Self = @This(); - - /// parent apprt object - parent: *T, - - /// our widget - menu_widget: *gtk.PopoverMenu, - - /// initialize the menu - pub fn init(self: *Self, parent: *T) void { - const object_type = switch (T) { - Window => "window", - Surface => "surface", - else => unreachable, - }; - - var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, 1, 0); - defer builder.deinit(); - - const menu_model = builder.getObject(gio.MenuModel, "menu").?; - - const menu_widget = gtk.PopoverMenu.newFromModelFull(menu_model, .{ .nested = true }); - - // If this menu has an arrow, don't modify the horizontal alignment - // or you get visual anomalies. See PR #6087. Otherwise set the - // horizontal alignment to `start` so that the top left corner of - // the menu aligns with the point that the menu is popped up at. - if (!arrow) menu_widget.as(gtk.Widget).setHalign(.start); - - menu_widget.as(gtk.Popover).setHasArrow(@intFromBool(arrow)); - - _ = gtk.Popover.signals.closed.connect( - menu_widget, - *Self, - gtkRefocusTerm, - self, - .{}, - ); - - self.* = .{ - .parent = parent, - .menu_widget = menu_widget, - }; - } - - pub fn setParent(self: *const Self, widget: *gtk.Widget) void { - self.menu_widget.as(gtk.Widget).setParent(widget); - } - - pub fn asWidget(self: *const Self) *gtk.Widget { - return self.menu_widget.as(gtk.Widget); - } - - pub fn isVisible(self: *const Self) bool { - return self.menu_widget.as(gtk.Widget).getVisible() != 0; - } - - /// Refresh the menu. Right now that means enabling/disabling the "Copy" - /// menu item based on whether there is an active selection or not, but - /// that may change in the future. - pub fn refresh(self: *const Self) void { - const window: *gtk.Window, const has_selection: bool = switch (T) { - Window => window: { - const has_selection = if (self.parent.actionSurface()) |core_surface| - core_surface.hasSelection() - else - false; - - break :window .{ self.parent.window.as(gtk.Window), has_selection }; - }, - Surface => surface: { - const window = self.parent.container.window() orelse return; - const has_selection = self.parent.core_surface.hasSelection(); - break :surface .{ window.window.as(gtk.Window), has_selection }; - }, - else => unreachable, - }; - - const action_map: *gio.ActionMap = gobject.ext.cast(gio.ActionMap, window) orelse return; - const action: *gio.SimpleAction = gobject.ext.cast( - gio.SimpleAction, - action_map.lookupAction("copy") orelse return, - ) orelse return; - action.setEnabled(@intFromBool(has_selection)); - } - - /// Pop up the menu at the given coordinates - pub fn popupAt(self: *const Self, x: c_int, y: c_int) void { - const rect: gdk.Rectangle = .{ - .f_x = x, - .f_y = y, - .f_width = 1, - .f_height = 1, - }; - const popover = self.menu_widget.as(gtk.Popover); - popover.setPointingTo(&rect); - self.refresh(); - popover.popup(); - } - - /// Refocus tab that lost focus because of the popover menu - fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.c) void { - const window: *Window = switch (T) { - Window => self.parent, - Surface => self.parent.container.window() orelse return, - else => unreachable, - }; - - window.focusCurrentTab(); - } - }; -} diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css deleted file mode 100644 index 1ea2aeb4b..000000000 --- a/src/apprt/gtk/style-dark.css +++ /dev/null @@ -1,8 +0,0 @@ -.transparent { - background-color: transparent; -} - -.terminal-window .notebook paned > separator { - background-color: rgba(36, 36, 36, 1); - background-clip: content-box; -} diff --git a/src/apprt/gtk/style-hc-dark.css b/src/apprt/gtk/style-hc-dark.css deleted file mode 100644 index a9aa2dcc0..000000000 --- a/src/apprt/gtk/style-hc-dark.css +++ /dev/null @@ -1,3 +0,0 @@ -.transparent { - background-color: transparent; -} diff --git a/src/apprt/gtk/style-hc.css b/src/apprt/gtk/style-hc.css deleted file mode 100644 index a9aa2dcc0..000000000 --- a/src/apprt/gtk/style-hc.css +++ /dev/null @@ -1,3 +0,0 @@ -.transparent { - background-color: transparent; -} diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css deleted file mode 100644 index 777ab3810..000000000 --- a/src/apprt/gtk/style.css +++ /dev/null @@ -1,116 +0,0 @@ -label.url-overlay { - padding: 4px 8px 4px 8px; - outline-style: solid; - outline-color: #555555; - outline-width: 1px; -} - -label.url-overlay:hover { - opacity: 0; -} - -label.url-overlay.left { - border-radius: 0px 6px 0px 0px; -} - -label.url-overlay.right { - border-radius: 6px 0px 0px 0px; -} - -label.url-overlay.hidden { - opacity: 0; -} - -label.size-overlay { - padding: 4px 8px 4px 8px; - border-radius: 6px 6px 6px 6px; - outline-style: solid; - outline-width: 1px; - outline-color: #555555; -} - -label.size-overlay.hidden { - opacity: 0; -} - -window.ssd.no-border-radius { - /* Without clearing the border radius, at least on Mutter with - * gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting - * that this will mitigate. - */ - border-radius: 0 0; -} - -.transparent { - background-color: transparent; -} - -.terminal-window .notebook paned > separator { - background-color: rgba(250, 250, 250, 1); - background-clip: content-box; - - /* This works around the oversized drag area for the right side of GtkPaned. - * - * Upstream Gtk issue: - * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 - * - * Ghostty issue: - * https://github.com/ghostty-org/ghostty/issues/3020 - * - * Without this, it's not possible to select the first character on the - * right-hand side of a split. - */ - margin: 0; - padding: 0; -} - -.clipboard-overlay { - border-radius: 10px; -} - -.clipboard-content-view { - filter: blur(0px); - transition: filter 0.3s ease; - border-radius: 10px; -} - -.clipboard-content-view.blurred { - filter: blur(5px); -} - -.command-palette-search { - font-size: 1.25rem; - padding: 4px; - -gtk-icon-size: 20px; -} - -.command-palette-search > image:first-child { - margin-left: 8px; - margin-right: 4px; -} - -.command-palette-search > image:last-child { - margin-left: 4px; - margin-right: 8px; -} - -banner.child_exited_normally revealer widget { - background-color: rgba(38, 162, 105, 0.5); - /* after GTK 4.16 is a requirement, switch to the following: - /* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */ -} - -banner.child_exited_abnormally revealer widget { - background-color: rgba(192, 28, 40, 0.5); - /* after GTK 4.16 is a requirement, switch to the following: - /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ -} - -/* -* Change the color of an error progressbar -*/ -progressbar.error trough progress { - background-color: rgb(192, 28, 40); - /* after GTK 4.16 is a requirement, switch to the following: */ - /* background-color: var(--error-bg-color); */ -} diff --git a/src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp diff --git a/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp b/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp deleted file mode 100644 index 90de02845..000000000 --- a/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp +++ /dev/null @@ -1,25 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } -} diff --git a/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp b/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp deleted file mode 100644 index ab48552db..000000000 --- a/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp +++ /dev/null @@ -1,102 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Copy"); - action: "win.copy"; - } - - item { - label: _("Paste"); - action: "win.paste"; - } - } - - section { - item { - label: _("Clear"); - action: "win.clear"; - } - - item { - label: _("Reset"); - action: "win.reset"; - } - } - - section { - submenu { - label: _("Split"); - - item { - label: _("Change Title…"); - action: "win.prompt-title"; - } - - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } - - submenu { - label: _("Tab"); - - item { - label: _("New Tab"); - action: "win.new-tab"; - } - - item { - label: _("Close Tab"); - action: "win.close-tab"; - } - } - - submenu { - label: _("Window"); - - item { - label: _("New Window"); - action: "win.new-window"; - } - - item { - label: _("Close Window"); - action: "win.close"; - } - } - } - - section { - submenu { - label: _("Config"); - - item { - label: _("Open Configuration"); - action: "app.open-config"; - } - - item { - label: _("Reload Configuration"); - action: "app.reload-config"; - } - } - } -} diff --git a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp deleted file mode 100644 index 3273aa81c..000000000 --- a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp +++ /dev/null @@ -1,116 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Copy"); - action: "win.copy"; - } - - item { - label: _("Paste"); - action: "win.paste"; - } - } - - section { - item { - label: _("New Window"); - action: "win.new-window"; - } - - item { - label: _("Close Window"); - action: "win.close"; - } - } - - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - - item { - label: _("Close Tab"); - action: "win.close-tab"; - } - } - - section { - submenu { - label: _("Split"); - - item { - label: _("Change Title…"); - action: "win.prompt-title"; - } - - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } - } - - section { - item { - label: _("Clear"); - action: "win.clear"; - } - - item { - label: _("Reset"); - action: "win.reset"; - } - } - - section { - item { - label: _("Command Palette"); - action: "win.toggle-command-palette"; - } - - item { - label: _("Terminal Inspector"); - action: "win.toggle-inspector"; - } - - item { - label: _("Open Configuration"); - action: "app.open-config"; - } - - item { - label: _("Reload Configuration"); - action: "app.reload-config"; - } - } - - section { - item { - label: _("About Ghostty"); - action: "win.about"; - } - - item { - label: _("Quit"); - action: "app.quit"; - } - } -} diff --git a/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp deleted file mode 100644 index b250073d2..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to read from the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp deleted file mode 100644 index d880df5f2..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to write to the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.2/ccw-paste.blp b/src/apprt/gtk/ui/1.2/ccw-paste.blp deleted file mode 100644 index f26921803..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-paste.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Warning: Potentially Unsafe Paste"); - body: _("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("Paste") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp b/src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp diff --git a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp index b844d6347..845909eb3 100644 --- a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp +++ b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp @@ -1,7 +1,8 @@ using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. using Adw 1; -Adw.MessageDialog config_errors_dialog { +template $GhosttyConfigErrorsDialog: $GhosttyDialog { heading: _("Configuration Errors"); body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); @@ -21,8 +22,7 @@ Adw.MessageDialog config_errors_dialog { bottom-margin: 8; left-margin: 8; right-margin: 8; - - buffer: TextBuffer error_message {}; + buffer: bind (template.config as <$GhosttyConfig>).diagnostics-buffer; } }; } diff --git a/src/apprt/gtk-ng/ui/1.2/debug-warning.blp b/src/apprt/gtk/ui/1.2/debug-warning.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/debug-warning.blp rename to src/apprt/gtk/ui/1.2/debug-warning.blp diff --git a/src/apprt/gtk-ng/ui/1.2/resize-overlay.blp b/src/apprt/gtk/ui/1.2/resize-overlay.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/resize-overlay.blp rename to src/apprt/gtk/ui/1.2/resize-overlay.blp diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp new file mode 100644 index 000000000..f22f2c09a --- /dev/null +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -0,0 +1,286 @@ +using Gtk 4.0; +using Adw 1; + +Adw.StatusPage error_page { + icon-name: "computer-fail-symbolic"; + title: _("Oh, no."); + description: _("Unable to acquire an OpenGL context for rendering."); + + child: LinkButton { + label: "https://ghostty.org/docs/help/gtk-opengl-context"; + uri: "https://ghostty.org/docs/help/gtk-opengl-context"; + }; +} + +Overlay terminal_page { + focusable: false; + focus-on-click: false; + + child: Box { + hexpand: true; + vexpand: true; + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + allowed-apis: gl; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + }; + + [overlay] + ProgressBar progress_bar_overlay { + styles [ + "osd", + ] + + visible: false; + halign: fill; + valign: start; + } + + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; + transition-type: crossfade; + transition-duration: 500; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + + [overlay] + $GhosttySurfaceChildExited child_exited_overlay { + visible: bind template.child-exited; + close-request => $child_exited_close(); + } + + [overlay] + $GhosttyResizeOverlay resize_overlay {} + + [overlay] + Label url_left { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: start; + valign: end; + label: bind template.mouse-hover-url; + + EventControllerMotion url_ec_motion { + enter => $url_mouse_enter(); + leave => $url_mouse_leave(); + } + } + + [overlay] + Label url_right { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: end; + valign: end; + label: bind template.mouse-hover-url; + } + + // Event controllers for interactivity + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } + + DropTarget drop_target { + drop => $drop(); + actions: copy; + } +} + +template $GhosttySurface: Adw.Bin { + styles [ + "surface", + ] + + notify::bell-ringing => $notify_bell_ringing(); + notify::config => $notify_config(); + notify::error => $notify_error(); + notify::mouse-hover-url => $notify_mouse_hover_url(); + notify::mouse-hidden => $notify_mouse_hidden(); + notify::mouse-shape => $notify_mouse_shape(); + // Some history: we used to use a Stack here and swap between the + // terminal and error pages as needed. But a Stack doesn't play nice + // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with + // a manual programmatic child swap fixed this. So if you ever change + // this, be sure to test many splits! + // + // [^1]: https://github.com/ghostty-org/ghostty/issues/8533 + child: terminal_page; +} + +IMMulticontext im_context { + input-purpose: terminal; + preedit-start => $im_preedit_start(); + preedit-changed => $im_preedit_changed(); + preedit-end => $im_preedit_end(); + commit => $im_commit(); +} + +menu context_menu_model { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "surface.prompt-title"; + } + + item { + label: _("Split Up"); + action: "split-tree.new-split"; + target: "up"; + } + + item { + label: _("Split Down"); + action: "split-tree.new-split"; + target: "down"; + } + + item { + label: _("Split Left"); + action: "split-tree.new-split"; + target: "left"; + } + + item { + label: _("Split Right"); + action: "split-tree.new-split"; + target: "right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.3/debug-warning.blp b/src/apprt/gtk/ui/1.3/debug-warning.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.3/debug-warning.blp rename to src/apprt/gtk/ui/1.3/debug-warning.blp diff --git a/src/apprt/gtk-ng/ui/1.3/surface-child-exited.blp b/src/apprt/gtk/ui/1.3/surface-child-exited.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.3/surface-child-exited.blp rename to src/apprt/gtk/ui/1.3/surface-child-exited.blp diff --git a/src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp deleted file mode 100644 index ad0b5c01f..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp +++ /dev/null @@ -1,85 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to read from the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: ListBox { - selection-mode: none; - - styles [ - "boxed-list-separate", - ] - - Overlay { - styles [ - "osd", - "clipboard-overlay", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 200; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - } - - Adw.SwitchRow remember_choice { - title: _("Remember choice for this split"); - subtitle: _("Reload configuration to show this prompt again"); - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp deleted file mode 100644 index b71131940..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp +++ /dev/null @@ -1,81 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to write to the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: ListBox { - selection-mode: none; - - styles [ - "boxed-list-separate", - ] - - Overlay { - styles [ - "osd", - "clipboard-overlay", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 200; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - } - } - - Adw.SwitchRow remember_choice { - title: _("Remember choice for this split"); - subtitle: _("Reload configuration to show this prompt again"); - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/ccw-paste.blp b/src/apprt/gtk/ui/1.5/ccw-paste.blp deleted file mode 100644 index a5f909526..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-paste.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Warning: Potentially Unsafe Paste"); - body: _("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("Paste") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp index a84482091..473fb1f06 100644 --- a/src/apprt/gtk/ui/1.5/command-palette.blp +++ b/src/apprt/gtk/ui/1.5/command-palette.blp @@ -2,8 +2,9 @@ using Gtk 4.0; using Gio 2.0; using Adw 1; -Adw.Dialog command-palette { +Adw.Dialog dialog { content-width: 700; + closed => $closed(); Adw.ToolbarView { top-bar-style: flat; @@ -11,9 +12,11 @@ Adw.Dialog command-palette { [top] Adw.HeaderBar { [title] - SearchEntry search { + Gtk.SearchEntry search { hexpand: true; placeholder-text: _("Execute a command…"); + stop-search => $search_stopped(); + activate => $search_activated(); styles [ "command-palette-search", @@ -21,24 +24,25 @@ Adw.Dialog command-palette { } } - ScrolledWindow { + Gtk.ScrolledWindow { min-content-height: 300; - ListView view { + Gtk.ListView view { show-separators: true; single-click-activate: true; + activate => $row_activated(); - model: SingleSelection model { - model: FilterListModel { + model: Gtk.SingleSelection model { + model: Gtk.FilterListModel { incremental: true; - filter: AnyFilter { - StringFilter { + filter: Gtk.AnyFilter { + Gtk.StringFilter { expression: expr item as <$GhosttyCommand>.title; search: bind search.text; } - StringFilter { + Gtk.StringFilter { expression: expr item as <$GhosttyCommand>.action-key; search: bind search.text; } @@ -54,18 +58,18 @@ Adw.Dialog command-palette { "rich-list", ] - factory: BuilderListItemFactory { - template ListItem { - child: Box { + factory: Gtk.BuilderListItemFactory { + template Gtk.ListItem { + child: Gtk.Box { orientation: horizontal; spacing: 10; tooltip-text: bind template.item as <$GhosttyCommand>.description; - Box { + Gtk.Box { orientation: vertical; hexpand: true; - Label { + Gtk.Label { ellipsize: end; halign: start; wrap: false; @@ -78,7 +82,7 @@ Adw.Dialog command-palette { label: bind template.item as <$GhosttyCommand>.title; } - Label { + Gtk.Label { ellipsize: end; halign: start; wrap: false; @@ -93,7 +97,7 @@ Adw.Dialog command-palette { } } - ShortcutLabel { + Gtk.ShortcutLabel { accelerator: bind template.item as <$GhosttyCommand>.action; valign: center; } diff --git a/src/apprt/gtk/ui/1.5/config-errors-dialog.blp b/src/apprt/gtk/ui/1.5/config-errors-dialog.blp deleted file mode 100644 index 793e9295a..000000000 --- a/src/apprt/gtk/ui/1.5/config-errors-dialog.blp +++ /dev/null @@ -1,28 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -Adw.AlertDialog config_errors_dialog { - heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); - - responses [ - ignore: _("Ignore"), - reload: _("Reload Configuration") suggested, - ] - - extra-child: ScrolledWindow { - min-content-width: 500; - min-content-height: 100; - - TextView { - editable: false; - cursor-visible: false; - top-margin: 8; - bottom-margin: 8; - left-margin: 8; - right-margin: 8; - - buffer: TextBuffer error_message {}; - } - }; -} diff --git a/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp b/src/apprt/gtk/ui/1.5/imgui-widget.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/imgui-widget.blp rename to src/apprt/gtk/ui/1.5/imgui-widget.blp diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk/ui/1.5/inspector-widget.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/inspector-widget.blp rename to src/apprt/gtk/ui/1.5/inspector-widget.blp diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk/ui/1.5/inspector-window.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/inspector-window.blp rename to src/apprt/gtk/ui/1.5/inspector-window.blp diff --git a/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp b/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp deleted file mode 100644 index d23594ba4..000000000 --- a/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp +++ /dev/null @@ -1,16 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -Adw.AlertDialog prompt_title_dialog { - heading: _("Change Terminal Title"); - body: _("Leave blank to restore the default title."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("OK") destructive, - ] - - focus-widget: title_entry; - - extra-child: Entry title_entry {}; -} diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/split-tree-split.blp rename to src/apprt/gtk/ui/1.5/split-tree-split.blp diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk/ui/1.5/split-tree.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/split-tree.blp rename to src/apprt/gtk/ui/1.5/split-tree.blp diff --git a/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk/ui/1.5/surface-title-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp rename to src/apprt/gtk/ui/1.5/surface-title-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk/ui/1.5/tab.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/tab.blp rename to src/apprt/gtk/ui/1.5/tab.blp diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/window.blp rename to src/apprt/gtk/ui/1.5/window.blp diff --git a/src/apprt/gtk/ui/README.md b/src/apprt/gtk/ui/README.md deleted file mode 100644 index b9dc732b6..000000000 --- a/src/apprt/gtk/ui/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# GTK UI files - -This directory is for storing GTK blueprints. GTK blueprints are compiled into -GTK resource builder `.ui` files by `blueprint-compiler` at build time and then -converted into an embeddable resource by `glib-compile-resources`. - -Blueprint files should be stored in directories that represent the minimum -Adwaita version needed to use that resource. Blueprint files should also be -formatted using `blueprint-compiler format` as well to ensure consistency -(formatting will be checked in CI). - -`blueprint-compiler` version 0.16.0 or newer is required to compile Blueprint -files. If your system does not have `blueprint-compiler` or does not have a -new enough version you can use the generated source tarballs, which contain -precompiled versions of the blueprints. diff --git a/src/apprt/gtk-ng/weak_ref.zig b/src/apprt/gtk/weak_ref.zig similarity index 100% rename from src/apprt/gtk-ng/weak_ref.zig rename to src/apprt/gtk/weak_ref.zig diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index 2dbe5a7a0..3c1da2b21 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -7,7 +7,7 @@ const gdk = @import("gdk"); const Config = @import("../../config.zig").Config; const input = @import("../../input.zig"); const key = @import("key.zig"); -const ApprtWindow = @import("Window.zig"); +const ApprtWindow = @import("class/window.zig").Window; pub const noop = @import("winproto/noop.zig"); pub const x11 = @import("winproto/x11.zig"); diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index fb732b756..ed69736f8 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -5,7 +5,7 @@ const gdk = @import("gdk"); const Config = @import("../../../config.zig").Config; const input = @import("../../../input.zig"); -const ApprtWindow = @import("../Window.zig"); +const ApprtWindow = @import("../class/window.zig").Window; const log = std.log.scoped(.winproto_noop); diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 0973499cc..5837e3e5e 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -12,7 +12,7 @@ const wayland = @import("wayland"); const Config = @import("../../../config.zig").Config; const input = @import("../../../input.zig"); -const ApprtWindow = @import("../Window.zig"); +const ApprtWindow = @import("../class/window.zig").Window; const wl = wayland.client.wl; const org = wayland.client.org; @@ -114,11 +114,13 @@ pub const App = struct { return false; } - if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ - .major = 1, - .minor = 0, - .patch = 4, - }) == .lt) { + if (self.context.xdg_wm_dialog_present and + layer_shell.getLibraryVersion().order(.{ + .major = 1, + .minor = 0, + .patch = 4, + }) == .lt) + { log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); return false; } @@ -127,11 +129,8 @@ pub const App = struct { } pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { - const window = apprt_window.window.as(gtk.Window); - + const window = apprt_window.as(gtk.Window); layer_shell.initForWindow(window); - layer_shell.setLayer(window, .top); - layer_shell.setNamespace(window, "ghostty-quick-terminal"); } fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { @@ -257,7 +256,7 @@ pub const Window = struct { ) !Window { _ = alloc; - const gtk_native = apprt_window.window.as(gtk.Native); + const gtk_native = apprt_window.as(gtk.Native); const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface; // This should never fail, because if we're being called at this point @@ -364,7 +363,11 @@ pub const Window = struct { /// Update the blur state of the window. fn syncBlur(self: *Window) !void { const manager = self.app_context.kde_blur_manager orelse return; - const blur = self.apprt_window.config.background_blur; + const config = if (self.apprt_window.getConfig()) |v| + v.get() + else + return; + const blur = config.@"background-blur"; if (self.blur_token) |tok| { // Only release token when transitioning from blurred -> not blurred @@ -392,7 +395,7 @@ pub const Window = struct { } fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { - return switch (self.apprt_window.config.window_decoration) { + return switch (self.apprt_window.getWindowDecoration()) { .auto => self.app_context.default_deco_mode orelse .Client, .client => .Client, .server => .Server, @@ -401,12 +404,23 @@ pub const Window = struct { } fn syncQuickTerminal(self: *Window) !void { - const window = self.apprt_window.window.as(gtk.Window); - const config = &self.apprt_window.config; + const window = self.apprt_window.as(gtk.Window); + const config = if (self.apprt_window.getConfig()) |v| + v.get() + else + return; + + layer_shell.setLayer(window, switch (config.@"gtk-quick-terminal-layer") { + .overlay => .overlay, + .top => .top, + .bottom => .bottom, + .background => .background, + }); + layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace"); layer_shell.setKeyboardMode( window, - switch (config.quick_terminal_keyboard_interactivity) { + switch (config.@"quick-terminal-keyboard-interactivity") { .none => .none, .@"on-demand" => on_demand: { if (layer_shell.getProtocolVersion() < 4) { @@ -419,7 +433,7 @@ pub const Window = struct { }, ); - const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { + const anchored_edge: ?layer_shell.ShellEdge = switch (config.@"quick-terminal-position") { .left => .left, .right => .right, .top => .top, @@ -470,14 +484,14 @@ pub const Window = struct { monitor: *gdk.Monitor, apprt_window: *ApprtWindow, ) callconv(.c) void { - const window = apprt_window.window.as(gtk.Window); - const config = &apprt_window.config; + const window = apprt_window.as(gtk.Window); + const config = if (apprt_window.getConfig()) |v| v.get() else return; var monitor_size: gdk.Rectangle = undefined; monitor.getGeometry(&monitor_size); - const dims = config.quick_terminal_size.calculate( - config.quick_terminal_position, + const dims = config.@"quick-terminal-size".calculate( + config.@"quick-terminal-position", .{ .width = @intCast(monitor_size.f_width), .height = @intCast(monitor_size.f_height), diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 624de03f8..8956a29ed 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -20,7 +20,7 @@ pub const c = @cImport({ const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; -const ApprtWindow = @import("../Window.zig"); +const ApprtWindow = @import("../class/window.zig").Window; const log = std.log.scoped(.gtk_x11); @@ -170,8 +170,7 @@ pub const App = struct { pub const Window = struct { app: *App, - config: *const ApprtWindow.DerivedConfig, - gtk_window: *adw.ApplicationWindow, + apprt_window: *ApprtWindow, x11_surface: *gdk_x11.X11Surface, blur_region: Region = .{}, @@ -183,9 +182,8 @@ pub const Window = struct { ) !Window { _ = alloc; - const surface = apprt_window.window.as( - gtk.Native, - ).getSurface() orelse return error.NotX11Surface; + const surface = apprt_window.as(gtk.Native).getSurface() orelse + return error.NotX11Surface; const x11_surface = gobject.ext.cast( gdk_x11.X11Surface, @@ -194,8 +192,7 @@ pub const Window = struct { return .{ .app = app, - .config = &apprt_window.config, - .gtk_window = apprt_window.window, + .apprt_window = apprt_window, .x11_surface = x11_surface, }; } @@ -221,10 +218,10 @@ pub const Window = struct { var x: f64 = 0; var y: f64 = 0; - self.gtk_window.as(gtk.Native).getSurfaceTransform(&x, &y); + self.apprt_window.as(gtk.Native).getSurfaceTransform(&x, &y); // Transform surface coordinates to device coordinates. - const scale: f64 = @floatFromInt(self.gtk_window.as(gtk.Widget).getScaleFactor()); + const scale: f64 = @floatFromInt(self.apprt_window.as(gtk.Widget).getScaleFactor()); x *= scale; y *= scale; @@ -242,7 +239,7 @@ pub const Window = struct { } pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self.config.window_decoration) { + return switch (self.apprt_window.getWindowDecoration()) { .auto, .client => true, .server, .none => false, }; @@ -257,14 +254,15 @@ pub const Window = struct { // and I think it's not really noticeable enough to justify the effort. // (Wayland also has this visual artifact anyway...) - const gtk_widget = self.gtk_window.as(gtk.Widget); + const gtk_widget = self.apprt_window.as(gtk.Widget); + const config = if (self.apprt_window.getConfig()) |v| v.get() else return; // Transform surface coordinates to device coordinates. - const scale = self.gtk_window.as(gtk.Widget).getScaleFactor(); + const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = self.config.background_blur; + const blur = config.@"background-blur"; log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), @@ -306,7 +304,7 @@ pub const Window = struct { }; hints.flags.decorations = true; - hints.decorations.all = switch (self.config.window_decoration) { + hints.decorations.all = switch (self.apprt_window.getWindowDecoration()) { .server => true, .auto, .client, .none => false, }; diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index c9948f3ee..89b8c2235 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -24,6 +24,8 @@ pub const CursorPos = struct { pub const IMEPos = struct { x: f64, y: f64, + width: f64, + height: f64, }; /// The clipboard type. @@ -37,13 +39,13 @@ pub const Clipboard = enum(Backing) { // Our backing isn't is as small as we can in Zig, but a full // C int if we're binding to C APIs. const Backing = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => c_int, + .gtk => c_int, else => u2, }; /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( Clipboard, .{ .name = "GhosttyApprtClipboard" }, ), @@ -72,7 +74,7 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( ClipboardRequest, .{ .name = "GhosttyClipboardRequest" }, ), diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index f76e3d05a..a4070c668 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -112,7 +112,6 @@ pub const Message = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { .gtk, - .@"gtk-ng", => @import("gobject").ext.defineBoxed( ChildExited, .{ .name = "GhosttyApprtChildExited" }, diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig new file mode 100644 index 000000000..940207619 --- /dev/null +++ b/src/benchmark/IsSymbol.zig @@ -0,0 +1,145 @@ +//! This benchmark tests the throughput of grapheme break calculation. +//! This is a common operation in terminal character printing for terminals +//! that support grapheme clustering. +const IsSymbol = @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 UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); +const symbols = @import("../unicode/symbols.zig"); + +const log = std.log.scoped(.@"is-symbol-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// Which test to run. + mode: Mode = .ziglyph, + + /// 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, +}; + +pub const Mode = enum { + /// "Naive" ziglyph implementation. + ziglyph, + + /// Ghostty's table-based approach. + table, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*IsSymbol { + const ptr = try alloc.create(IsSymbol); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *IsSymbol, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn benchmark(self: *IsSymbol) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .ziglyph => stepZiglyph, + .table => stepTable, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @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; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn stepZiglyph(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + std.mem.doNotOptimizeAway(symbols.isSymbol(cp)); + } + } + } +} + +fn stepTable(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + std.mem.doNotOptimizeAway(symbols.table.get(cp)); + } + } + } +} + +test IsSymbol { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *IsSymbol = 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 97bb9c683..3b1c905eb 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -10,6 +10,7 @@ pub const Action = enum { @"grapheme-break", @"terminal-parser", @"terminal-stream", + @"is-symbol", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -25,6 +26,7 @@ pub const Action = enum { .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), + .@"is-symbol" => @import("IsSymbol.zig"), }; } }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 49bb17289..3a59125fc 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -5,6 +5,7 @@ pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const TerminalParser = @import("TerminalParser.zig"); +pub const IsSymbol = @import("IsSymbol.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/build/Config.zig b/src/build/Config.zig index fd892f16c..b11e8850d 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -61,6 +61,7 @@ emit_termcap: bool = false, emit_test_exe: bool = false, emit_xcframework: bool = false, emit_webdata: bool = false, +emit_unicode_table_gen: bool = false, /// Environmental properties env: std.process.EnvMap, @@ -299,6 +300,12 @@ pub fn init(b: *std.Build) !Config { "Build and install test executables with 'build'", ) orelse false; + config.emit_unicode_table_gen = b.option( + bool, + "emit-unicode-table-gen", + "Build and install executables that generate unicode tables with 'build'", + ) orelse false; + config.emit_bench = b.option( bool, "emit-bench", diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 6123582b7..d889f2350 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -20,11 +20,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // Get the resources we're going to inject into the source tarball. const alloc = b.allocator; var resources: std.ArrayListUnmanaged(Resource) = .empty; - { - const gtk = SharedDeps.gtkDistResources(b); - try resources.append(alloc, gtk.resources_c); - try resources.append(alloc, gtk.resources_h); - } { const gtk = SharedDeps.gtkNgDistResources(b); try resources.append(alloc, gtk.resources_c); diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index 9dcc67a31..324cc94c2 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -3,7 +3,7 @@ const GhosttyI18n = @This(); const std = @import("std"); const builtin = @import("builtin"); const Config = @import("Config.zig"); -const gresource = @import("../apprt/gtk/gresource.zig"); +const gresource = @import("../apprt/gtk/build/gresource.zig"); const internal_os = @import("../os/main.zig"); const domain = "com.mitchellh.ghostty"; @@ -78,7 +78,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { // Not cacheable due to the gresource files xgettext.has_side_effects = true; - inline for (gresource.blueprint_files) |blp| { + inline for (gresource.blueprints) |blp| { const path = std.fmt.comptimePrint( "src/apprt/gtk/ui/{[major]}.{[minor]}/{[name]s}.blp", blp, diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index f338f8b98..fd3f91d89 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -41,6 +41,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { .metallib = undefined, }; try result.initTarget(b, cfg.target); + if (cfg.emit_unicode_table_gen) result.unicode_tables.install(b); return result; } @@ -562,8 +563,7 @@ pub fn add( switch (self.config.app_runtime) { .none => {}, - .gtk => try self.addGTK(step), - .@"gtk-ng" => try self.addGtkNg(step), + .gtk => try self.addGtkNg(step), } } @@ -714,11 +714,11 @@ pub fn gtkNgDistResources( resources_c: DistResource, resources_h: DistResource, } { - const gresource = @import("../apprt/gtk-ng/build/gresource.zig"); + const gresource = @import("../apprt/gtk/build/gresource.zig"); const gresource_xml = gresource_xml: { const xml_exe = b.addExecutable(.{ .name = "generate_gresource_xml", - .root_source_file = b.path("src/apprt/gtk-ng/build/gresource.zig"), + .root_source_file = b.path("src/apprt/gtk/build/gresource.zig"), .target = b.graph.host, }); const xml_run = b.addRunArtifact(xml_exe); @@ -726,7 +726,7 @@ pub fn gtkNgDistResources( // Run our blueprint compiler across all of our blueprint files. const blueprint_exe = b.addExecutable(.{ .name = "gtk_blueprint_compiler", - .root_source_file = b.path("src/apprt/gtk-ng/build/blueprint.zig"), + .root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"), .target = b.graph.host, }); blueprint_exe.linkLibC(); @@ -789,234 +789,6 @@ pub fn gtkNgDistResources( generate_h.addFileInput(b.path(path)); } - return .{ - .resources_c = .{ - .dist = "src/apprt/gtk-ng/ghostty_resources.c", - .generated = resources_c, - }, - .resources_h = .{ - .dist = "src/apprt/gtk-ng/ghostty_resources.h", - .generated = resources_h, - }, - }; -} - -/// Setup the dependencies for the GTK apprt build. The GTK apprt -/// is particularly involved compared to others so we pull this out -/// into a dedicated function. -fn addGTK( - self: *const SharedDeps, - step: *std.Build.Step.Compile, -) !void { - const b = step.step.owner; - const target = step.root_module.resolved_target.?; - const optimize = step.root_module.optimize.?; - - const gobject_ = b.lazyDependency("gobject", .{ - .target = target, - .optimize = optimize, - }); - if (gobject_) |gobject| { - const gobject_imports = .{ - .{ "adw", "adw1" }, - .{ "gdk", "gdk4" }, - .{ "gio", "gio2" }, - .{ "glib", "glib2" }, - .{ "gobject", "gobject2" }, - .{ "gtk", "gtk4" }, - .{ "xlib", "xlib2" }, - }; - inline for (gobject_imports) |import| { - const name, const module = import; - step.root_module.addImport(name, gobject.module(module)); - } - } - - step.linkSystemLibrary2("gtk4", dynamic_link_opts); - step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); - - if (self.config.x11) { - step.linkSystemLibrary2("X11", dynamic_link_opts); - if (gobject_) |gobject| { - step.root_module.addImport( - "gdk_x11", - gobject.module("gdkx114"), - ); - } - } - - if (self.config.wayland) wayland: { - // These need to be all be called to note that we need them. - const wayland_dep_ = b.lazyDependency("wayland", .{}); - const wayland_protocols_dep_ = b.lazyDependency( - "wayland_protocols", - .{}, - ); - const plasma_wayland_protocols_dep_ = b.lazyDependency( - "plasma_wayland_protocols", - .{}, - ); - - // Unwrap or return, there are no more dependencies below. - const wayland_dep = wayland_dep_ orelse break :wayland; - const wayland_protocols_dep = wayland_protocols_dep_ orelse break :wayland; - const plasma_wayland_protocols_dep = plasma_wayland_protocols_dep_ orelse break :wayland; - - // Note that zig_wayland cannot be lazy because lazy dependencies - // can't be imported since they don't exist and imports are - // resolved at compile time of the build. - const zig_wayland_dep = b.dependency("zig_wayland", .{}); - const Scanner = @import("zig_wayland").Scanner; - const scanner = Scanner.create(zig_wayland_dep.builder, .{ - .wayland_xml = wayland_dep.path("protocol/wayland.xml"), - .wayland_protocols = wayland_protocols_dep.path(""), - }); - - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/blur.xml"), - ); - // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"), - ); - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), - ); - scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); - - scanner.generate("wl_compositor", 1); - scanner.generate("org_kde_kwin_blur_manager", 1); - scanner.generate("org_kde_kwin_server_decoration_manager", 1); - scanner.generate("org_kde_kwin_slide_manager", 1); - scanner.generate("xdg_activation_v1", 1); - - step.root_module.addImport("wayland", b.createModule(.{ - .root_source_file = scanner.result, - })); - if (gobject_) |gobject| step.root_module.addImport( - "gdk_wayland", - gobject.module("gdkwayland4"), - ); - - if (b.lazyDependency("gtk4_layer_shell", .{ - .target = target, - .optimize = optimize, - })) |gtk4_layer_shell| { - const layer_shell_module = gtk4_layer_shell.module("gtk4-layer-shell"); - if (gobject_) |gobject| layer_shell_module.addImport( - "gtk", - gobject.module("gtk4"), - ); - step.root_module.addImport( - "gtk4-layer-shell", - layer_shell_module, - ); - - // IMPORTANT: gtk4-layer-shell must be linked BEFORE - // wayland-client, as it relies on shimming libwayland's APIs. - if (b.systemIntegrationOption("gtk4-layer-shell", .{})) { - step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts); - } else { - // gtk4-layer-shell *must* be dynamically linked, - // so we don't add it as a static library - const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); - b.installArtifact(shared_lib); - step.linkLibrary(shared_lib); - } - } - - step.linkSystemLibrary2("wayland-client", dynamic_link_opts); - } - - { - // Get our gresource c/h files and add them to our build. - const dist = gtkDistResources(b); - step.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} }); - step.addIncludePath(dist.resources_h.path(b).dirname()); - } -} - -/// Creates the resources that can be prebuilt for our dist build. -pub fn gtkDistResources( - b: *std.Build, -) struct { - resources_c: DistResource, - resources_h: DistResource, -} { - const gresource = @import("../apprt/gtk/gresource.zig"); - - const gresource_xml = gresource_xml: { - const xml_exe = b.addExecutable(.{ - .name = "generate_gresource_xml", - .root_source_file = b.path("src/apprt/gtk/gresource.zig"), - .target = b.graph.host, - }); - const xml_run = b.addRunArtifact(xml_exe); - - const blueprint_exe = b.addExecutable(.{ - .name = "gtk_blueprint_compiler", - .root_source_file = b.path("src/apprt/gtk/blueprint_compiler.zig"), - .target = b.graph.host, - }); - blueprint_exe.linkLibC(); - blueprint_exe.linkSystemLibrary2("gtk4", dynamic_link_opts); - blueprint_exe.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); - - for (gresource.blueprint_files) |blueprint_file| { - const blueprint_run = b.addRunArtifact(blueprint_exe); - blueprint_run.addArgs(&.{ - b.fmt("{d}", .{blueprint_file.major}), - b.fmt("{d}", .{blueprint_file.minor}), - }); - const ui_file = blueprint_run.addOutputFileArg(b.fmt( - "{d}.{d}/{s}.ui", - .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }, - )); - blueprint_run.addFileArg(b.path(b.fmt( - "src/apprt/gtk/ui/{d}.{d}/{s}.blp", - .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }, - ))); - - xml_run.addFileArg(ui_file); - } - - break :gresource_xml xml_run.captureStdOut(); - }; - - const generate_c = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-source", - "--target", - }); - const resources_c = generate_c.addOutputFileArg("ghostty_resources.c"); - generate_c.addFileArg(gresource_xml); - for (gresource.dependencies) |file| { - generate_c.addFileInput(b.path(file)); - } - - const generate_h = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-header", - "--target", - }); - const resources_h = generate_h.addOutputFileArg("ghostty_resources.h"); - generate_h.addFileArg(gresource_xml); - for (gresource.dependencies) |file| { - generate_h.addFileInput(b.path(file)); - } - return .{ .resources_c = .{ .dist = "src/apprt/gtk/ghostty_resources.c", diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 219b8589a..d71c5ca95 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -4,14 +4,16 @@ const std = @import("std"); const Config = @import("Config.zig"); /// The exe. -exe: *std.Build.Step.Compile, +props_exe: *std.Build.Step.Compile, +symbols_exe: *std.Build.Step.Compile, /// The output path for the unicode tables -output: std.Build.LazyPath, +props_output: std.Build.LazyPath, +symbols_output: std.Build.LazyPath, pub fn init(b: *std.Build, uucode_tables_zig: std.Build.LazyPath) !UnicodeTables { - const exe = b.addExecutable(.{ - .name = "unigen", + const props_exe = b.addExecutable(.{ + .name = "props-unigen", .root_module = b.createModule(.{ .root_source_file = b.path("src/unicode/props.zig"), .target = b.graph.host, @@ -21,31 +23,54 @@ pub fn init(b: *std.Build, uucode_tables_zig: std.Build.LazyPath) !UnicodeTables }), }); + const symbols_exe = b.addExecutable(.{ + .name = "symbols-unigen", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unicode/symbols.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), + }); + if (b.lazyDependency("uucode", .{ .target = b.graph.host, .@"tables.zig" = uucode_tables_zig, .build_config_path = b.path("src/build/uucode_config.zig"), })) |dep| { - exe.root_module.addImport("uucode", dep.module("uucode")); + inline for (&.{ props_exe, symbols_exe }) |exe| { + exe.root_module.addImport("uucode", dep.module("uucode")); + } } - const run = b.addRunArtifact(exe); - const output = run.addOutputFileArg("tables.zig"); + const props_run = b.addRunArtifact(props_exe); + const symbols_run = b.addRunArtifact(symbols_exe); + const props_output = props_run.addOutputFileArg("tables.zig"); + const symbols_output = symbols_run.addOutputFileArg("tables.zig"); + return .{ - .exe = exe, - .output = output, + .props_exe = props_exe, + .symbols_exe = symbols_exe, + .props_output = props_output, + .symbols_output = symbols_output, }; } /// Add the "unicode_tables" import. pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void { - self.output.addStepDependencies(&step.step); + self.props_output.addStepDependencies(&step.step); step.root_module.addAnonymousImport("unicode_tables", .{ - .root_source_file = self.output, + .root_source_file = self.props_output, + }); + self.symbols_output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("symbols_tables", .{ + .root_source_file = self.symbols_output, }); } /// Install the exe pub fn install(self: *const UnicodeTables, b: *std.Build) void { - b.installArtifact(self.exe); + b.installArtifact(self.props_exe); + b.installArtifact(self.symbols_exe); } diff --git a/src/cli/args.zig b/src/cli/args.zig index 0ff3dc047..4db0a29a2 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -260,6 +260,7 @@ fn formatInvalidValue( } fn formatValues(comptime T: type, key: []const u8, writer: anytype) std.mem.Allocator.Error!void { + @setEvalBranchQuota(2000); const typeinfo = @typeInfo(T); inline for (typeinfo.@"struct".fields) |f| { if (std.mem.eql(u8, key, f.name)) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 5ffd01871..221a7cf93 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -73,6 +73,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` // by setting the value to "bright". .{ "bold-is-bright", compatBoldIsBright }, + + // 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 }, }); /// The font families to use. @@ -654,6 +658,18 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// Available since: 1.2.0 @"selection-clear-on-typing": bool = true, +/// Whether to clear selected text after copying. This defaults to `false`. +/// +/// When set to `true`, the selection will be automatically cleared after +/// any copy operation that invokes the `copy_to_clipboard` keyboard binding. +/// Importantly, this will not clear the selection if the copy operation +/// was invoked via `copy-on-select`. +/// +/// When set to `false`, the selection remains visible after copying, allowing +/// to see what was copied and potentially perform additional operations +/// on the same selection. +@"selection-clear-on-copy": bool = false, + /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no /// contrast (e.g. black on black). This value is the contrast ratio as defined @@ -2132,6 +2148,44 @@ keybind: Keybinds = .{}, /// terminal would be half a screen tall, and 500 pixels wide. @"quick-terminal-size": QuickTerminalSize = .{}, +/// The layer of the quick terminal window. The higher the layer, +/// the more windows the quick terminal may conceal. +/// +/// Valid values are: +/// +/// * `overlay` +/// +/// The quick terminal appears in front of all windows. +/// +/// * `top` (default) +/// +/// The quick terminal appears in front of normal windows but behind +/// fullscreen overlays like lock screens. +/// +/// * `bottom` +/// +/// The quick terminal appears behind normal windows but in front of +/// wallpapers and other windows in the background layer. +/// +/// * `background` +/// +/// The quick terminal appears behind all windows. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-layer": QuickTerminalLayer = .top, +/// The namespace for the quick terminal window. +/// +/// This is an identifier that is used by the Wayland compositor and/or +/// scripts to determine the type of layer surfaces and to possibly apply +/// unique effects. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-namespace": [:0]const u8 = "ghostty-quick-terminal", + /// The screen where the quick terminal should show up. /// /// Valid values are: @@ -2925,14 +2979,29 @@ else /// /// If `false`, each new ghostty process will launch a separate application. /// -/// The default value is `desktop` which will default to `true` if Ghostty -/// detects that it was launched from the `.desktop` file such as an app -/// launcher (like Gnome Shell) or by D-Bus activation. If Ghostty is launched -/// from the command line, it will default to `false`. +/// If `detect`, Ghostty will assume true (single instance) unless one of +/// the following scenarios is found: +/// +/// 1. TERM_PROGRAM environment variable is a non-empty value. In this +/// case, we assume Ghostty is being launched from a graphical terminal +/// session and you want a dedicated instance. +/// +/// 2. Any CLI arguments exist. In this case, we assume you are passing +/// custom Ghostty configuration. Single instance mode inherits the +/// configuration from when it was launched, so we must disable single +/// instance to load the new configuration. +/// +/// If either of these scenarios is producing a false positive, you can +/// set this configuration explicitly to the behavior you want. +/// +/// The pre-1.2 option `desktop` has been deprecated. Please replace +/// this with `detect`. +/// +/// The default value is `detect`. /// /// Note that debug builds of Ghostty have a separate single-instance ID /// so you can test single instance without conflicting with release builds. -@"gtk-single-instance": GtkSingleInstance = .desktop, +@"gtk-single-instance": GtkSingleInstance = .default, /// When enabled, the full GTK titlebar is displayed instead of your window /// manager's simple titlebar. The behavior of this option will vary with your @@ -3054,25 +3123,6 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", -/// The mechanism used to launch Ghostty. This should generally not be -/// set by users, see the warning below. -/// -/// WARNING: This is a low-level configuration that is not intended to be -/// modified by users. All the values will be automatically detected as they -/// are needed by Ghostty. This is only here in case our detection logic is -/// incorrect for your environment or for developers who want to test -/// Ghostty's behavior in different, forced environments. -/// -/// This is set using the standard `no-[value]`, `[value]` syntax separated -/// by commas. Example: "no-desktop,systemd". Specific details about the -/// available values are documented on LaunchProperties in the code. Since -/// this isn't intended to be modified by users, the documentation is -/// lighter than the other configurations and users are expected to -/// refer to the code for details. -/// -/// Available since: 1.2.0 -@"launched-from": ?LaunchSource = null, - /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -3437,7 +3487,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { .windows => {}, - // Fast-path if we are Linux and have no args. + // Fast-path if we are Linux/BSD and have no args. .linux, .freebsd => if (std.os.argv.len <= 1) return, // Everything else we have to at least try because it may @@ -3867,10 +3917,9 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); - // Ensure our launch source is properly set. - if (self.@"launched-from" == null) { - self.@"launched-from" = .detect(); - } + // Used for a variety of defaults. See the function docs as well the + // specific variable use sites for more details. + const probable_cli = probableCliEnvironment(); // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does @@ -3896,12 +3945,14 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { - // If we have no working directory set, our default depends on - // whether we were launched from the desktop or elsewhere. - .desktop => "home", - .cli, .dbus, .systemd => "inherit", - }; + const wd = self.@"working-directory" orelse if (probable_cli) + // From the CLI, we want to inherit where we were launched from. + "inherit" + else + // Otherwise we typically just want the home directory because + // our pwd is probably a runtime state dir or root or something + // (launchers and desktop environments typically do this). + "home"; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this @@ -3921,12 +3972,9 @@ pub fn finalize(self: *Config) !void { if (internal_os.isFlatpak()) break :shell_env; // If we were launched from the desktop, our SHELL env var - // will represent our SHELL at login time. We want to use the - // latest shell from /etc/passwd or directory services. - switch (self.@"launched-from".?) { - .desktop, .dbus, .systemd => break :shell_env, - .cli => {}, - } + // will represent our SHELL at login time. We only want to + // read from SHELL if we're in a probable CLI environment. + if (!probable_cli) break :shell_env; if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -3979,6 +4027,23 @@ pub fn finalize(self: *Config) !void { } } + // Apprt-specific defaults + switch (build_config.app_runtime) { + .none => {}, + .gtk => { + switch (self.@"gtk-single-instance") { + .true, .false => {}, + + // For detection, we assume single instance unless we're + // in a CLI environment, then we disable single instance. + .detect => self.@"gtk-single-instance" = if (probable_cli) + .false + else + .true, + } + }, + } + // If we have the special value "inherit" then set it to null which // does the same. In the future we should change to a tagged union. if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; @@ -4106,6 +4171,23 @@ fn compatGtkTabsLocation( return false; } +fn compatGtkSingleInstance( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-single-instance")); + + if (std.mem.eql(u8, value orelse "", "desktop")) { + self.@"gtk-single-instance" = .detect; + return true; + } + + return false; +} + fn compatCursorInvertFgBg( self: *Config, alloc: Allocator, @@ -4443,6 +4525,32 @@ fn equalField(comptime T: type, old: T, new: T) bool { } } +/// This runs a heuristic to determine if we are likely running +/// Ghostty in a CLI environment. We need this to change some behaviors. +/// We should keep the set of behaviors that depend on this as small +/// as possible because magic sucks, but each place is well documented. +fn probableCliEnvironment() bool { + switch (builtin.os.tag) { + // Windows has its own problems, just ignore it for now since + // its not a real supported target and GTK via WSL2 assuming + // single instance is probably fine. + .windows => return false, + else => {}, + } + + // If we have TERM_PROGRAM set to a non-empty value, we assume + // a graphical terminal environment. + if (std.posix.getenv("TERM_PROGRAM")) |v| { + if (v.len > 0) return true; + } + + // CLI arguments makes things probable + if (std.os.argv.len > 1) return true; + + // Unlikely CLI environment + return false; +} + /// This is used to "replay" the configuration. See loadTheme for details. const Replay = struct { const Step = union(enum) { @@ -5475,10 +5583,11 @@ pub const Keybinds = struct { else .{ .ctrl = true, .shift = true }; - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'c' }, .mods = mods }, .{ .copy_to_clipboard = {} }, + .{ .performable = true }, ); try self.set.put( alloc, @@ -5785,15 +5894,24 @@ pub const Keybinds = struct { else .{ .alt = true }; - // Cmd+N for goto tab N + // Cmd/Alt+N for goto tab N const start: u21 = '1'; const end: u21 = '8'; - var i: u21 = start; - while (i <= end) : (i += 1) { + comptime var i: u21 = start; + inline while (i <= end) : (i += 1) { + // We register BOTH the physical `digit_N` key and the unicode + // `N` key. This allows most keyboard layouts to work with + // this shortcut. Namely, AZERTY doesn't produce unicode `N` + // for their digit keys (they're on shifted keys on the same + // physical keys). + try self.set.putFlags( alloc, .{ - .key = .{ .unicode = i }, + .key = .{ .physical = @field( + inputpkg.Key, + std.fmt.comptimePrint("digit_{u}", .{i}), + ) }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, @@ -5806,6 +5924,22 @@ pub const Keybinds = struct { .performable = !builtin.target.os.tag.isDarwin(), }, ); + + // Important: this must be the LAST binding set so that the + // libghostty trigger API returns this one for the action, + // so that things like the macOS tab bar key equivalent label + // work properly. + try self.set.putFlags( + alloc, + .{ + .key = .{ .unicode = i }, + .mods = mods, + }, + .{ .goto_tab = (i - start) + 1 }, + .{ + .performable = !builtin.target.os.tag.isDarwin(), + }, + ); } try self.set.putFlags( alloc, @@ -7049,9 +7183,11 @@ pub const MacShortcuts = enum { /// See gtk-single-instance pub const GtkSingleInstance = enum { - desktop, false, true, + detect, + + pub const default: GtkSingleInstance = .detect; }; /// See gtk-tabs-location @@ -7073,7 +7209,7 @@ pub const GtkTitlebarStyle = enum(c_int) { tabs, pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( GtkTitlebarStyle, .{ .name = "GhosttyGtkTitlebarStyle" }, ), @@ -7165,6 +7301,14 @@ pub const QuickTerminalPosition = enum { center, }; +/// See quick-terminal-layer +pub const QuickTerminalLayer = enum { + overlay, + top, + bottom, + background, +}; + /// See quick-terminal-size pub const QuickTerminalSize = struct { primary: ?Size = null, @@ -7633,7 +7777,7 @@ pub const WindowDecoration = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( WindowDecoration, .{ .name = "GhosttyConfigWindowDecoration" }, ), @@ -7935,34 +8079,6 @@ pub const Duration = struct { } }; -pub const LaunchSource = enum { - /// Ghostty was launched via the CLI. This is the default if - /// no other source is detected. - cli, - - /// Ghostty was launched in a desktop environment (not via the CLI). - /// This is used to determine some behaviors such as how to read - /// settings, whether single instance defaults to true, etc. - desktop, - - /// Ghostty was started via dbus activation. - dbus, - - /// Ghostty was started via systemd activation. - systemd, - - pub fn detect() LaunchSource { - return if (internal_os.launchedFromDesktop()) - .desktop - else if (internal_os.launchedByDbusActivation()) - .dbus - else if (internal_os.launchedBySystemd()) - .systemd - else - .cli; - } -}; - pub const WindowPadding = struct { const Self = @This(); @@ -8627,6 +8743,27 @@ test "theme specifying light/dark sets theme usage in conditional state" { } } +test "compatibility: gtk-single-instance desktop" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--gtk-single-instance=desktop", + } }; + try cfg.loadIter(alloc, &it); + + // We need to test this BEFORE finalize, because finalize will + // convert our detect to a real value. + try testing.expectEqual( + GtkSingleInstance.detect, + cfg.@"gtk-single-instance", + ); + } +} + test "compatibility: removed cursor-invert-fg-bg" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 57da22109..28b45ceed 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1266,7 +1266,7 @@ pub fn SplitTree(comptime V: type) type { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( Self, .{ // To get the type name we get the non-qualified type name diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 2b5f591a5..ad9590d70 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -92,7 +92,6 @@ pub const AddOptions = struct { pub const AddError = Allocator.Error || - Face.GetMetricsError || error{ /// There's no more room in the collection. CollectionFull, @@ -127,7 +126,7 @@ pub fn add( // Scale factor to adjust the size of the added face. const scale_factor = self.scaleFactor( - try owned_face.getMetrics(), + owned_face.getMetrics(), opts.size_adjustment, ); @@ -225,7 +224,7 @@ fn getFaceFromEntry( // entry now that we have a loaded face. entry.scale_factor = .{ .scale = self.scaleFactor( - try face.getMetrics(), + face.getMetrics(), entry.scale_factor.adjustment, ), }; @@ -592,7 +591,7 @@ fn scaleFactor( @branchHint(.unlikely); // If we can't load the primary face, just use 1.0 as the scale factor. const primary_face = self.getFace(.{ .idx = 0 }) catch return 1.0; - self.primary_face_metrics = primary_face.getMetrics() catch return 1.0; + self.primary_face_metrics = primary_face.getMetrics(); } const primary_metrics = self.primary_face_metrics.?; @@ -652,7 +651,7 @@ fn scaleFactor( return primary_metric / face_metric; } -const UpdateMetricsError = font.Face.GetMetricsError || error{ +const UpdateMetricsError = error{ CannotLoadPrimaryFont, }; @@ -663,7 +662,7 @@ const UpdateMetricsError = font.Face.GetMetricsError || error{ pub fn updateMetrics(self: *Collection) UpdateMetricsError!void { const primary_face = self.getFace(.{ .idx = 0 }) catch return error.CannotLoadPrimaryFont; - self.primary_face_metrics = try primary_face.getMetrics(); + self.primary_face_metrics = primary_face.getMetrics(); var metrics = Metrics.calc(self.primary_face_metrics.?); @@ -1288,8 +1287,8 @@ test "adjusted sizes" { // The chosen metric should match. { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const fallback_metrics = try (try c.getFace(fallback_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const fallback_metrics = (try c.getFace(fallback_idx)).getMetrics(); try std.testing.expectApproxEqAbs( @field(primary_metrics, metric).?, @@ -1302,8 +1301,8 @@ test "adjusted sizes" { // Resize should keep that relationship. try c.setSize(.{ .points = 37, .xdpi = 96, .ydpi = 96 }); { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const fallback_metrics = try (try c.getFace(fallback_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const fallback_metrics = (try c.getFace(fallback_idx)).getMetrics(); try std.testing.expectApproxEqAbs( @field(primary_metrics, metric).?, @@ -1359,8 +1358,8 @@ test "adjusted sizes" { // Test fallback to lineHeight() (ex_height and cap_height not defined in symbols font). { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const symbol_metrics = try (try c.getFace(symbol_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const symbol_metrics = (try c.getFace(symbol_idx)).getMetrics(); try std.testing.expectApproxEqAbs( primary_metrics.lineHeight(), diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 59ea48e18..a3d3f54f7 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -281,17 +281,25 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { // centered in the cell. if (comptime tag == .cell_height) { // We split the difference in half because we want to - // center the baseline in the cell. + // center the baseline in the cell. If the difference + // is odd, one more pixel is added/removed on top than + // on the bottom. if (new > original) { - const diff = (new - original) / 2; - self.cell_baseline +|= diff; - self.underline_position +|= diff; - self.strikethrough_position +|= diff; + const diff = new - original; + const diff_bottom = diff / 2; + const diff_top = diff - diff_bottom; + self.cell_baseline +|= diff_bottom; + self.underline_position +|= diff_top; + self.strikethrough_position +|= diff_top; + self.overline_position +|= @as(i32, @intCast(diff_top)); } else { - const diff = (original - new) / 2; - self.cell_baseline -|= diff; - self.underline_position -|= diff; - self.strikethrough_position -|= diff; + const diff = original - new; + const diff_bottom = diff / 2; + const diff_top = diff - diff_bottom; + self.cell_baseline -|= diff_bottom; + self.underline_position -|= diff_top; + self.strikethrough_position -|= diff_top; + self.overline_position -|= @as(i32, @intCast(diff_top)); } } }, @@ -509,19 +517,24 @@ test "Metrics: adjust cell height smaller" { var set: ModifierSet = .{}; defer set.deinit(alloc); - try set.put(alloc, .cell_height, .{ .percent = 0.5 }); + // We choose numbers such that the subtracted number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're removing 25 pixels: 12 on the bottom, 13 on top. + try set.put(alloc, .cell_height, .{ .percent = 0.75 }); var m: Metrics = init(); m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; + m.overline_position = 0; m.cell_height = 100; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(@as(u32, 50), m.cell_height); - try testing.expectEqual(@as(u32, 25), m.cell_baseline); - try testing.expectEqual(@as(u32, 30), m.underline_position); - try testing.expectEqual(@as(u32, 5), m.strikethrough_position); + try testing.expectEqual(@as(u32, 75), m.cell_height); + try testing.expectEqual(@as(u32, 38), m.cell_baseline); + try testing.expectEqual(@as(u32, 42), m.underline_position); + try testing.expectEqual(@as(u32, 17), m.strikethrough_position); + try testing.expectEqual(@as(i32, -13), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } @@ -532,19 +545,24 @@ test "Metrics: adjust cell height larger" { var set: ModifierSet = .{}; defer set.deinit(alloc); - try set.put(alloc, .cell_height, .{ .percent = 2 }); + // We choose numbers such that the added number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're adding 75 pixels: 37 on the bottom, 38 on top. + try set.put(alloc, .cell_height, .{ .percent = 1.75 }); var m: Metrics = init(); m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; + m.overline_position = 0; m.cell_height = 100; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(@as(u32, 200), m.cell_height); - try testing.expectEqual(@as(u32, 100), m.cell_baseline); - try testing.expectEqual(@as(u32, 105), m.underline_position); - try testing.expectEqual(@as(u32, 80), m.strikethrough_position); + try testing.expectEqual(@as(u32, 175), m.cell_height); + try testing.expectEqual(@as(u32, 87), m.cell_baseline); + try testing.expectEqual(@as(u32, 93), m.underline_position); + try testing.expectEqual(@as(u32, 68), m.strikethrough_position); + try testing.expectEqual(@as(i32, 38), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 980b0314c..e79fd117f 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -332,11 +332,16 @@ const GlyphKey = struct { const Context = struct { pub fn hash(_: Context, key: GlyphKey) u64 { - return @bitCast(Packed.from(key)); + // Packed is a u64 but std.hash.int improves uniformity and + // avoids collisions in our hashmap. + const packed_key = Packed.from(key); + return std.hash.int(@as(u64, @bitCast(packed_key))); } pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { - return Packed.from(a) == Packed.from(b); + // Packed checks glyphs but in most cases the glyphs are NOT + // equal so the first check leads to increased throughput. + return a.glyph == b.glyph and Packed.from(a) == Packed.from(b); } }; diff --git a/src/font/face.zig b/src/font/face.zig index 2902f97ae..9da3c30f6 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -59,7 +59,7 @@ pub const DesiredSize = struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( DesiredSize, .{ .name = "GhosttyFontDesiredSize" }, ), diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index cb6e6b1f7..bd7e16e0d 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -574,19 +574,12 @@ pub const Face = struct { }; } - pub const GetMetricsError = error{ - CopyTableError, - InvalidHeadTable, - InvalidPostTable, - InvalidHheaTable, - }; - /// Get the `FaceMetrics` for this face. - pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + pub fn getMetrics(self: *Face) font.Metrics.FaceMetrics { const ct_font = self.font; // Read the 'head' table out of the font data. - const head: opentype.Head = head: { + const head_: ?opentype.Head = head: { // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but // the table format is byte-identical to the 'head' table, so if we // can't find 'head' we try 'bhed' instead before failing. @@ -597,29 +590,26 @@ pub const Face = struct { const data = ct_font.copyTable(head_tag) orelse ct_font.copyTable(bhed_tag) orelse - return error.CopyTableError; + break :head null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :head opentype.Head.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream, - => error.InvalidHeadTable, - }; + log.warn("error parsing head table: {}", .{err}); + break :head null; }; }; // Read the 'post' table out of the font data. - const post: opentype.Post = post: { + const post_: ?opentype.Post = post: { const tag = macos.text.FontTableTag.init("post"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + const data = ct_font.copyTable(tag) orelse break :post null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :post opentype.Post.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream => error.InvalidPostTable, - }; + log.warn("error parsing post table: {}", .{err}); + break :post null; }; }; @@ -637,96 +627,110 @@ pub const Face = struct { }; // Read the 'hhea' table out of the font data. - const hhea: opentype.Hhea = hhea: { + const hhea_: ?opentype.Hhea = hhea: { const tag = macos.text.FontTableTag.init("hhea"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + const data = ct_font.copyTable(tag) orelse break :hhea null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :hhea opentype.Hhea.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream => error.InvalidHheaTable, - }; + log.warn("error parsing hhea table: {}", .{err}); + break :hhea null; }; }; - const units_per_em: f64 = @floatFromInt(head.unitsPerEm); + const units_per_em: f64 = + if (head_) |head| + @floatFromInt(head.unitsPerEm) + else + @floatFromInt(self.font.getUnitsPerEm()); const px_per_em: f64 = ct_font.getSize(); const px_per_unit: f64 = px_per_em / units_per_em; const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { + // If we couldn't get the hhea table, rely on metrics from CoreText. + const hhea = hhea_ orelse break :vertical_metrics .{ + self.font.getAscent(), + -self.font.getDescent(), + self.font.getLeading(), + }; + const hhea_ascent: f64 = @floatFromInt(hhea.ascender); const hhea_descent: f64 = @floatFromInt(hhea.descender); const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - - // If the font says to use typo metrics, trust it. - if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. - // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. - - if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - - if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); - break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, - }; - } - // If our font has no OS/2 table, then we just // blindly use the metrics from the hhea table. - break :vertical_metrics .{ + const os2 = os2_ orelse break :vertical_metrics .{ hhea_ascent * px_per_unit, hhea_descent * px_per_unit, hhea_line_gap * px_per_unit, }; + + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + + if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, + }; }; - // Some fonts have degenerate 'post' tables where the underline - // thickness (and often position) are 0. We consider them null - // if this is the case and use our own fallbacks when we calculate. - const has_broken_underline = post.underlineThickness == 0; + const underline_position, const underline_thickness = ul: { + const post = post_ orelse break :ul .{ null, null }; - // If the underline position isn't 0 then we do use it, - // even if the thickness is't properly specified. - const underline_position: ?f64 = if (has_broken_underline and post.underlinePosition == 0) - null - else - @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; - const underline_thickness = if (has_broken_underline) - null - else - @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const pos: ?f64 = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const thick: ?f64 = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + break :ul .{ pos, thick }; + }; // Similar logic to the underline above. const strikethrough_position, const strikethrough_thickness = st: { @@ -806,14 +810,41 @@ pub const Face = struct { const ic_width: ?f64 = ic_width: { const glyph = self.glyphIndex('水') orelse break :ic_width null; - var advances: [1]macos.graphics.Size = undefined; - _ = ct_font.getAdvancesForGlyphs( + const advance = ct_font.getAdvancesForGlyphs( .horizontal, &.{@intCast(glyph)}, - &advances, + null, ); - break :ic_width advances[0].width; + const bounds = ct_font.getBoundingRectsForGlyphs( + .horizontal, + &.{@intCast(glyph)}, + null, + ); + + // If the advance of the glyph is less than the width of the actual + // glyph then we just treat it as invalid since it's probably wrong + // and using it for size normalization will instead make the font + // way too big. + // + // This can sometimes happen if there's a CJK font that has been + // patched with the nerd fonts patcher and it butchers the advance + // values so the advance ends up half the width of the actual glyph. + if (bounds.size.width > advance) { + var buf: [1024]u8 = undefined; + const font_name = self.name(&buf) catch ""; + log.warn( + "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", + .{ + font_name, + bounds.size.width, + advance, + }, + ); + break :ic_width null; + } + + break :ic_width advance; }; return .{ @@ -962,7 +993,7 @@ test { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1027,7 +1058,7 @@ test "in-memory" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1054,7 +1085,7 @@ test "variable" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1085,7 +1116,7 @@ test "variable set variation" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 6c888672b..4fb82c502 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -594,6 +594,12 @@ pub const Face = struct { freetype.c.FT_PIXEL_MODE_GRAY, => {}, else => { + // Make sure the slot owns its bitmap, + // since we'll be modifying it here. + if (freetype.c.FT_GlyphSlot_Own_Bitmap(glyph) != 0) { + return error.BitmapHandlingError; + } + var converted: freetype.c.FT_Bitmap = undefined; freetype.c.FT_Bitmap_Init(&converted); if (freetype.c.FT_Bitmap_Convert( @@ -784,12 +790,8 @@ pub const Face = struct { return @as(F26Dot6, @bitCast(@as(i32, @intCast(v)))).to(f64); } - pub const GetMetricsError = error{ - CopyTableError, - }; - /// Get the `FaceMetrics` for this face. - pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + pub fn getMetrics(self: *Face) font.Metrics.FaceMetrics { const face = self.face; const size_metrics = face.handle.*.size.*.metrics; @@ -799,10 +801,10 @@ pub const Face = struct { assert(size_metrics.x_ppem == size_metrics.y_ppem); // Read the 'head' table out of the font data. - const head = face.getSfntTable(.head) orelse return error.CopyTableError; + const head_ = face.getSfntTable(.head); // Read the 'post' table out of the font data. - const post = face.getSfntTable(.post) orelse return error.CopyTableError; + const post_ = face.getSfntTable(.post); // Read the 'OS/2' table out of the font data. const os2_: ?*freetype.c.TT_OS2 = os2: { @@ -812,92 +814,128 @@ pub const Face = struct { }; // Read the 'hhea' table out of the font data. - const hhea = face.getSfntTable(.hhea) orelse return error.CopyTableError; + const hhea_ = face.getSfntTable(.hhea); - const units_per_em = head.Units_Per_EM; + // Whether the font is in a scalable format. We need to know this + // because many of the metrics provided by FreeType are invalid for + // non-scalable fonts. + const is_scalable = face.handle.*.face_flags & freetype.c.FT_FACE_FLAG_SCALABLE != 0; + + // We get the UPM from the head table. + // + // If we have no head, but it is a scalable face, take the UPM from + // FreeType's units_per_EM, otherwise we'll assume that UPM == PPEM. + const units_per_em: freetype.c.FT_UShort = + if (head_) |head| + head.Units_Per_EM + else if (is_scalable) + face.handle.*.units_per_EM + else + size_metrics.y_ppem; const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem); const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { + const hhea = hhea_ orelse { + // If we couldn't get the hhea table, rely on metrics from FreeType. + const ascender = f26dot6ToF64(size_metrics.ascender); + const descender = f26dot6ToF64(size_metrics.descender); + const height = f26dot6ToF64(size_metrics.height); + break :vertical_metrics .{ + ascender, + descender, + // We compute the line gap by adding the (negative) descender + // and subtracting the (positive) ascender from the line height + // to get the remaining gap size. + // + // NOTE: This might always be 0... but it doesn't hurt to do. + height + descender - ascender, + }; + }; + const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); const hhea_descent: f64 = @floatFromInt(hhea.Descender); const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - - // If the font says to use typo metrics, trust it. - // (The USE_TYPO_METRICS bit is bit 7) - if (os2.fsSelection & (1 << 7) != 0) { - break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - } - - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. - // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. - - if (hhea.Ascender != 0 or hhea.Descender != 0) { - break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - } - - if (os2_ascent != 0 or os2_descent != 0) { - break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - } - - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); - break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, - }; - } - // If our font has no OS/2 table, then we just // blindly use the metrics from the hhea table. - break :vertical_metrics .{ + const os2 = os2_ orelse break :vertical_metrics .{ hhea_ascent * px_per_unit, hhea_descent * px_per_unit, hhea_line_gap * px_per_unit, }; + + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + // (The USE_TYPO_METRICS bit is bit 7) + if (os2.fsSelection & (1 << 7) != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.Ascender != 0 or hhea.Descender != 0) { + break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + } + + if (os2_ascent != 0 or os2_descent != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, + }; }; - // Some fonts have degenerate 'post' tables where the underline - // thickness (and often position) are 0. We consider them null - // if this is the case and use our own fallbacks when we calculate. - const has_broken_underline = post.underlineThickness == 0; + const underline_position: ?f64, const underline_thickness: ?f64 = ul: { + const post = post_ orelse break :ul .{ null, null }; - // If the underline position isn't 0 then we do use it, - // even if the thickness is't properly specified. - const underline_position = if (has_broken_underline and post.underlinePosition == 0) - null - else - @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; - const underline_thickness = if (has_broken_underline) - null - else - @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const pos: ?f64 = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const thick: ?f64 = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + break :ul .{ pos, thick }; + }; // Similar logic to the underline above. const strikethrough_position, const strikethrough_thickness = st: { @@ -1007,7 +1045,31 @@ pub const Face = struct { .no_svg = true, }) catch break :ic_width null; - break :ic_width f26dot6ToF64(face.handle.*.glyph.*.advance.x); + const ft_glyph = face.handle.*.glyph; + + // If the advance of the glyph is less than the width of the actual + // glyph then we just treat it as invalid since it's probably wrong + // and using it for size normalization will instead make the font + // way too big. + // + // This can sometimes happen if there's a CJK font that has been + // patched with the nerd fonts patcher and it butchers the advance + // values so the advance ends up half the width of the actual glyph. + if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) { + var buf: [1024]u8 = undefined; + const font_name = self.name(&buf) catch ""; + log.warn( + "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", + .{ + font_name, + f26dot6ToF64(ft_glyph.*.metrics.width), + f26dot6ToF64(ft_glyph.*.advance.x), + }, + ); + break :ic_width null; + } + + break :ic_width f26dot6ToF64(ft_glyph.*.advance.x); }; return .{ @@ -1061,7 +1123,7 @@ test { alloc, &atlas, ft_font.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); } @@ -1071,7 +1133,7 @@ test { alloc, &atlas, ft_font.glyphIndex('A').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); try testing.expectEqual(@as(u32, 11), g1.height); @@ -1080,7 +1142,7 @@ test { alloc, &atlas, ft_font.glyphIndex('A').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); try testing.expectEqual(@as(u32, 20), g2.height); } @@ -1107,7 +1169,7 @@ test "color emoji" { alloc, &atlas, ft_font.glyphIndex('🥸').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); // Make sure this glyph has color @@ -1167,7 +1229,7 @@ test "mono to bgra" { alloc, &atlas, 3, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); } @@ -1231,7 +1293,7 @@ test "bitmap glyph" { alloc, &atlas, 77, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); // should render crisp diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 55e5604c3..11902d310 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -7,7 +7,7 @@ const Constraint = @import("face.zig").RenderOptions.Constraint; /// Get the a constraints for the provided codepoint. -pub fn getConstraint(cp: u21) Constraint { +pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { 0x2500...0x259f, => .{ @@ -1060,6 +1060,6 @@ pub fn getConstraint(cp: u21) Constraint { .align_vertical = .center, .group_width = 1.3001222493887530, }, - else => .none, + else => null, }; } diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index e314bbd02..a103a30ac 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -351,8 +351,8 @@ if __name__ == "__main__": const Constraint = @import("face.zig").RenderOptions.Constraint; /// Get the a constraints for the provided codepoint. -pub fn getConstraint(cp: u21) Constraint { +pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { """) f.write(generate_zig_switch_arms(patch_set, nerd_font)) - f.write("\n else => .none,\n };\n}\n") + f.write("\n else => null,\n };\n}\n") diff --git a/src/input/Binding.zig b/src/input/Binding.zig index bc7dae026..a6ceb3adb 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -744,7 +744,7 @@ pub const Action = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( Action, .{ .name = "GhosttyBindingAction" }, ), diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 908e1c828..e572806d1 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -166,7 +166,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { // GTK uses global OpenGL context so we load from null. apprt.gtk, - apprt.gtk_ng, => try prepareContext(null), apprt.embedded => { @@ -201,7 +200,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk, apprt.gtk_ng => { + apprt.gtk => { // GTK doesn't support threaded OpenGL operations as far as I can // tell, so we use the renderer thread to setup all the state // but then do the actual draws and texture syncs and all that @@ -223,7 +222,7 @@ pub fn threadExit(self: *const OpenGL) void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk, apprt.gtk_ng => { + apprt.gtk => { // We don't need to do any unloading for GTK because we may // be sharing the global bindings with other windows. }, @@ -238,7 +237,7 @@ pub fn displayRealized(self: *const OpenGL) void { _ = self; switch (apprt.runtime) { - apprt.gtk, apprt.gtk_ng => prepareContext(null) catch |err| { + apprt.gtk => prepareContext(null) catch |err| { log.warn( "Error preparing GL context in displayRealized, err={}", .{err}, diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 661b2a326..6ada849ed 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,12 +1,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; -const uucode = @import("uucode"); const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; +const symbols = @import("../unicode/symbols.zig").table; /// The possible cell content keys that exist. pub const Key = enum { @@ -237,13 +237,19 @@ pub fn isCovering(cp: u21) bool { /// Returns true of the codepoint is a "symbol-like" character, which /// for now we define as anything in a private use area and anything -/// in the "dingbats" unicode block. +/// in several unicode blocks: +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols /// /// In the future it may be prudent to expand this to encompass more /// symbol-like characters, and/or exclude some PUA sections. pub fn isSymbol(cp: u21) bool { - return uucode.get(.general_category, cp) == .other_private_use or - uucode.get(.block, cp) == .dingbats; + return symbols.get(cp); } /// Returns the appropriate `constraint_width` for diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1305dc3dc..8726f2951 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1551,15 +1551,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Look up the image const image = self.images.get(p.image_id) orelse { log.warn("image not found for placement image_id={}", .{p.image_id}); - return; + continue; }; // Get the texture const texture = switch (image.image) { - .ready => |t| t, + .ready, + .unload_ready, + => |t| t, else => { log.warn("image not ready for placement image_id={}", .{p.image_id}); - return; + continue; }, }; @@ -1909,7 +1911,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (img.isUnloading()) { img.deinit(self.alloc); self.images.removeByPtr(kv.key_ptr); - return; + continue; } if (img.isPending()) try img.upload(self.alloc, &self.api); } @@ -3064,7 +3066,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .thicken = self.config.font_thicken, .thicken_strength = self.config.font_thicken_strength, .cell_width = cell.gridWidth(), - .constraint = getConstraint(cp), + // If there's no Nerd Font constraint for this codepoint + // then, if it's a symbol, we constrain it to fit inside + // its cell(s), we don't modify the alignment at all. + .constraint = getConstraint(cp) orelse + if (cellpkg.isSymbol(cp)) .{ + .size_horizontal = .fit, + .size_vertical = .fit, + } else .none, .constraint_width = constraintWidth(cell_pin), }, ); diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse_shape.zig index e71d4fb3b..23ab215d6 100644 --- a/src/terminal/mouse_shape.zig +++ b/src/terminal/mouse_shape.zig @@ -49,7 +49,7 @@ pub const MouseShape = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( MouseShape, .{ .name = "GhosttyMouseShape" }, ), diff --git a/src/terminal/style.zig b/src/terminal/style.zig index deb2b8ec5..4f51cbc71 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -84,10 +84,23 @@ pub const Style = struct { } /// True if the style is equal to another style. + /// For performance do direct comparisons first. pub fn eql(self: Style, other: Style) bool { - // We convert the styles to packed structs and compare as integers - // because this is much faster than comparing each field separately. - return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other); + inline for (comptime std.meta.fields(Style)) |field| { + if (comptime std.meta.hasUniqueRepresentation(field.type)) { + if (@field(self, field.name) != @field(other, field.name)) { + return false; + } + } + } + inline for (comptime std.meta.fields(Style)) |field| { + if (comptime !std.meta.hasUniqueRepresentation(field.type)) { + if (!std.meta.eql(@field(self, field.name), @field(other, field.name))) { + return false; + } + } + } + return true; } /// Returns the bg color for a cell with this style given the cell diff --git a/src/unicode/lut.zig b/src/unicode/lut.zig index 95c6a3688..e10c5c0b8 100644 --- a/src/unicode/lut.zig +++ b/src/unicode/lut.zig @@ -83,7 +83,7 @@ pub fn Generator( block_len += 1; // If we still have space and we're not done with codepoints, - // we keep building up the bock. Conversely: we finalize this + // we keep building up the block. Conversely: we finalize this // block if we've filled it or are out of codepoints. if (block_len < block_size and cp != std.math.maxInt(u21)) continue; if (block_len < block_size) @memset(block[block_len..block_size], 0); @@ -136,7 +136,7 @@ pub fn Tables(comptime Elem: type) type { stage3: []const Elem, /// Given a codepoint, returns the mapping for that codepoint. - pub fn get(self: *const Self, cp: u21) Elem { + pub inline fn get(self: *const Self, cp: u21) Elem { const high = cp >> 8; const low = cp & 0xFF; return self.stage3[self.stage2[self.stage1[high] + low]]; @@ -173,6 +173,7 @@ pub fn Tables(comptime Elem: type) type { \\}; \\ }; \\} + \\ ); } }; diff --git a/src/unicode/main.zig b/src/unicode/main.zig index 2b0b8ef9c..e053976bc 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -10,5 +10,6 @@ pub const GraphemeBreakState = grapheme.BreakState; pub const isExtendedPictographic = props.isExtendedPictographic; test { + _ = @import("symbols.zig"); @import("std").testing.refAllDecls(@This()); } diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 579e59977..962fb16c4 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -168,17 +168,17 @@ pub fn main() !void { // This is not very fast in debug modes, so its commented by default. // IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CODEPOINTWIDTH CHANGES. -//test "tables match uucode" { -// const testing = std.testing; +// test "unicode props: tables match uucode" { +// const testing = std.testing; // -// const min = 0xFF + 1; // start outside ascii -// const max = std.math.maxInt(u21) + 1; -// for (min..max) |cp| { -// const t = table.get(@intCast(cp)); -// const uu = @min(2, @max(0, uucode.get(.wcwidth, @intCast(cp)))); -// if (t.width != uu) { -// std.log.warn("mismatch cp=U+{x} t={} uucode={}", .{ cp, t, uu }); -// try testing.expect(false); -// } -// } +// const min = 0xFF + 1; // start outside ascii +// const max = std.math.maxInt(u21) + 1; +// for (min..max) |cp| { +// const t = table.get(@intCast(cp)); +// const uu = @min(2, @max(0, uucode.get(.wcwidth, @intCast(cp)))); +// if (t.width != uu) { +// std.log.warn("mismatch cp=U+{x} t={} uucode={}", .{ cp, t, uu }); +// try testing.expect(false); +// } +// } //} diff --git a/src/unicode/symbols.zig b/src/unicode/symbols.zig new file mode 100644 index 000000000..20749bf91 --- /dev/null +++ b/src/unicode/symbols.zig @@ -0,0 +1,96 @@ +const props = @This(); +const std = @import("std"); +const assert = std.debug.assert; +const uucode = @import("uucode"); +const lut = @import("lut.zig"); + +/// The lookup tables for Ghostty. +pub const table = table: { + // This is only available after running main() below as part of the Ghostty + // build.zig, but due to Zig's lazy analysis we can still reference it here. + const generated = @import("symbols_tables").Tables(bool); + const Tables = lut.Tables(bool); + break :table Tables{ + .stage1 = &generated.stage1, + .stage2 = &generated.stage2, + .stage3 = &generated.stage3, + }; +}; + +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area and anything +/// in several unicode blocks: +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + const block = uucode.get(.block, cp); + return uucode.get(.general_category, cp) == .other_private_use or + block == .dingbats or + block == .emoticons or + block == .miscellaneous_symbols or + block == .enclosed_alphanumerics or + block == .enclosed_alphanumeric_supplement or + block == .miscellaneous_symbols_and_pictographs or + block == .transport_and_map_symbols; +} + +/// Runnable binary to generate the lookup tables and output to stdout. +pub fn main() !void { + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); + + const gen: lut.Generator( + bool, + struct { + pub fn get(ctx: @This(), cp: u21) !bool { + _ = ctx; + return isSymbol(cp); + } + + pub fn eql(ctx: @This(), a: bool, b: bool) bool { + _ = ctx; + return a == b; + } + }, + ) = .{}; + + const t = try gen.generate(alloc); + defer alloc.free(t.stage1); + defer alloc.free(t.stage2); + defer alloc.free(t.stage3); + try t.writeZig(std.io.getStdOut().writer()); + + // Uncomment when manually debugging to see our table sizes. + // std.log.warn("stage1={} stage2={} stage3={}", .{ + // t.stage1.len, + // t.stage2.len, + // t.stage3.len, + // }); +} + +// This is not very fast in debug modes, so its commented by default. +// IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CHANGES. +test "unicode symbols: tables match uucode" { + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + const testing = std.testing; + + for (0..std.math.maxInt(u21)) |cp| { + const t = table.get(@intCast(cp)); + const zg = isSymbol(@intCast(cp)); + + if (t != zg) { + std.log.warn("mismatch cp=U+{x} t={} zg={}", .{ cp, t, zg }); + try testing.expect(false); + } + } +} diff --git a/valgrind.supp b/valgrind.supp index bfc78bcff..eeb395d03 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -143,8 +143,8 @@ fun:g_main_context_dispatch_unlocked fun:g_main_context_iterate_unlocked.isra.0 fun:g_main_context_iteration - fun:apprt.gtk-ng.class.application.Application.run - fun:apprt.gtk-ng.App.run + fun:apprt.gtk.class.application.Application.run + fun:apprt.gtk.App.run fun:main_ghostty.main fun:callMain fun:callMainWithArgs @@ -177,8 +177,8 @@ fun:g_main_context_dispatch_unlocked fun:g_main_context_iterate_unlocked.isra.0 fun:g_main_context_iteration - fun:apprt.gtk-ng.class.application.Application.run - fun:apprt.gtk-ng.App.run + fun:apprt.gtk.class.application.Application.run + fun:apprt.gtk.App.run fun:main_ghostty.main fun:callMain fun:callMainWithArgs