mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Merge branch 'main' into localize-nautilus-script
This commit is contained in:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -4,11 +4,11 @@ build.zig.zon.json linguist-generated=true
|
||||
vendor/** linguist-vendored
|
||||
website/** linguist-documentation
|
||||
pkg/breakpad/vendor/** linguist-vendored
|
||||
pkg/cimgui/vendor/** linguist-vendored
|
||||
pkg/glfw/wayland-headers/** linguist-vendored
|
||||
pkg/libintl/config.h linguist-generated=true
|
||||
pkg/libintl/libintl.h linguist-generated=true
|
||||
pkg/simdutf/vendor/** linguist-vendored
|
||||
src/font/nerd_font_attributes.zig linguist-generated=true
|
||||
src/font/nerd_font_codepoint_tables.py linguist-generated=true
|
||||
src/font/res/** linguist-vendored
|
||||
src/terminal/res/** linguist-vendored
|
||||
|
||||
2
.github/workflows/publish-tag.yml
vendored
2
.github/workflows/publish-tag.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
mkdir blob
|
||||
mv appcast.xml blob/appcast.xml
|
||||
- name: Upload Appcast to R2
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
|
||||
with:
|
||||
r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
|
||||
r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
|
||||
6
.github/workflows/release-tip.yml
vendored
6
.github/workflows/release-tip.yml
vendored
@@ -232,7 +232,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -466,7 +466,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -650,7 +650,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: XCode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
|
||||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
@@ -217,6 +217,7 @@ jobs:
|
||||
x86_64-macos,
|
||||
aarch64-linux,
|
||||
x86_64-linux,
|
||||
x86_64-linux-musl,
|
||||
x86_64-windows,
|
||||
wasm32-freestanding,
|
||||
]
|
||||
@@ -456,7 +457,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -499,7 +500,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -764,7 +765,7 @@ jobs:
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
|
||||
- name: Xcode Select
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.0.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
|
||||
- name: Xcode Version
|
||||
run: xcodebuild -version
|
||||
@@ -843,6 +844,8 @@ jobs:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
runs-on: namespace-profile-ghostty-xsm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||
@@ -865,6 +868,8 @@ jobs:
|
||||
useDaemon: false # sometimes fails on short jobs
|
||||
- name: pinact check
|
||||
run: nix develop -c pinact run --check
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
prettier:
|
||||
if: github.repository == 'ghostty-org/ghostty'
|
||||
@@ -977,8 +982,6 @@ jobs:
|
||||
--check-sourced \
|
||||
--color=always \
|
||||
--severity=warning \
|
||||
--shell=bash \
|
||||
--external-sources \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
|
||||
translations:
|
||||
@@ -1082,7 +1085,7 @@ jobs:
|
||||
uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10
|
||||
|
||||
- name: Configure Namespace powered Buildx
|
||||
uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20
|
||||
uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21
|
||||
|
||||
- name: Download Source Tarball Artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
|
||||
8
.shellcheckrc
Normal file
8
.shellcheckrc
Normal file
@@ -0,0 +1,8 @@
|
||||
# ShellCheck <https://www.shellcheck.net/>
|
||||
# https://github.com/koalaman/shellcheck/wiki/Directive#shellcheckrc-file
|
||||
|
||||
# Allow opening any 'source'd file, even if not specified as input
|
||||
external-sources=true
|
||||
|
||||
# Assume bash by default
|
||||
shell=bash
|
||||
22
HACKING.md
22
HACKING.md
@@ -164,6 +164,28 @@ alejandra .
|
||||
|
||||
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
|
||||
|
||||
### ShellCheck
|
||||
|
||||
Bash scripts are checked with [ShellCheck](https://www.shellcheck.net/) in CI.
|
||||
|
||||
Nix users can use the following command to run ShellCheck over all of our scripts:
|
||||
|
||||
```
|
||||
nix develop -c shellcheck \
|
||||
--check-sourced \
|
||||
--severity=warning \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
```
|
||||
|
||||
Non-Nix users can [install ShellCheck](https://github.com/koalaman/shellcheck#user-content-installing) and then run:
|
||||
|
||||
```
|
||||
shellcheck \
|
||||
--check-sourced \
|
||||
--severity=warning \
|
||||
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
|
||||
```
|
||||
|
||||
### Updating the Zig Cache Fixed-Output Derivation Hash
|
||||
|
||||
The Nix package depends on a [fixed-output
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
},
|
||||
.z2d = .{
|
||||
// vancluever/z2d
|
||||
.url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
|
||||
.hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
|
||||
.url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||
.lazy = true,
|
||||
},
|
||||
.zig_objc = .{
|
||||
@@ -63,7 +63,7 @@
|
||||
},
|
||||
|
||||
// C libs
|
||||
.cimgui = .{ .path = "./pkg/cimgui", .lazy = true },
|
||||
.dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true },
|
||||
.fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true },
|
||||
.freetype = .{ .path = "./pkg/freetype", .lazy = true },
|
||||
.gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true },
|
||||
@@ -116,8 +116,8 @@
|
||||
// Other
|
||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||
.iterm2_themes = .{
|
||||
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
.hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
|
||||
.lazy = true,
|
||||
},
|
||||
},
|
||||
|
||||
23
build.zig.zon.json
generated
23
build.zig.zon.json
generated
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": {
|
||||
"name": "bindings",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM="
|
||||
},
|
||||
"N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ": {
|
||||
"name": "breakpad",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -44,15 +49,15 @@
|
||||
"url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz",
|
||||
"hash": "sha256-h9T4iT704I8iSXNgj/6/lCaKgTgLp5wS6IQZaMgKohI="
|
||||
},
|
||||
"N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3": {
|
||||
"N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI": {
|
||||
"name": "imgui",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
|
||||
},
|
||||
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
|
||||
"N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": {
|
||||
"name": "iterm2_themes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
"hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="
|
||||
},
|
||||
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
||||
"name": "jetbrains_mono",
|
||||
@@ -139,10 +144,10 @@
|
||||
"url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz",
|
||||
"hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM="
|
||||
},
|
||||
"z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": {
|
||||
"z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": {
|
||||
"name": "z2d",
|
||||
"url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
|
||||
"hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
"hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="
|
||||
},
|
||||
"zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": {
|
||||
"name": "zf",
|
||||
|
||||
26
build.zig.zon.nix
generated
26
build.zig.zon.nix
generated
@@ -82,6 +82,14 @@
|
||||
fetcher.${proto};
|
||||
in
|
||||
linkFarm name [
|
||||
{
|
||||
name = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr";
|
||||
path = fetchZigArtifact {
|
||||
name = "bindings";
|
||||
url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ";
|
||||
path = fetchZigArtifact {
|
||||
@@ -155,19 +163,19 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3";
|
||||
name = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI";
|
||||
path = fetchZigArtifact {
|
||||
name = "imgui";
|
||||
url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz";
|
||||
hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=";
|
||||
url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
|
||||
name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7";
|
||||
path = fetchZigArtifact {
|
||||
name = "iterm2_themes";
|
||||
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
|
||||
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
|
||||
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz";
|
||||
hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=";
|
||||
};
|
||||
}
|
||||
{
|
||||
@@ -307,11 +315,11 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T";
|
||||
name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ";
|
||||
path = fetchZigArtifact {
|
||||
name = "z2d";
|
||||
url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz";
|
||||
hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=";
|
||||
url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz";
|
||||
hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=";
|
||||
};
|
||||
}
|
||||
{
|
||||
|
||||
7
build.zig.zon.txt
generated
7
build.zig.zon.txt
generated
@@ -1,16 +1,17 @@
|
||||
git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732
|
||||
https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz
|
||||
https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz
|
||||
https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz
|
||||
https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz
|
||||
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
|
||||
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
|
||||
https://deps.files.ghostty.org/gettext-0.24.tar.gz
|
||||
https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz
|
||||
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
|
||||
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
|
||||
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
||||
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
|
||||
https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz
|
||||
https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz
|
||||
https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz
|
||||
https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz
|
||||
https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz
|
||||
@@ -25,11 +26,11 @@ https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.ta
|
||||
https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz
|
||||
https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz
|
||||
https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz
|
||||
https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz
|
||||
https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
|
||||
https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz
|
||||
https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz
|
||||
https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz
|
||||
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
||||
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
|
||||
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz
|
||||
https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz
|
||||
https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz
|
||||
|
||||
100
dist/linux/ghostty_nautilus.py
vendored
100
dist/linux/ghostty_nautilus.py
vendored
@@ -17,87 +17,51 @@
|
||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
from os.path import isdir
|
||||
from gi import require_version
|
||||
from gi.repository import Nautilus, GObject, Gio, GLib
|
||||
import os
|
||||
import gettext
|
||||
from gi.repository import Nautilus, GObject, Gio
|
||||
|
||||
DOMAIN = "com.mitchellh.ghostty"
|
||||
share_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
locale_dir = os.path.join(share_dir, "locale")
|
||||
_ = gettext.translation(DOMAIN, locale_dir, fallback=True).gettext
|
||||
|
||||
class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
session = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
self._systemd = None
|
||||
# Check if the this system runs under systemd, per sd_booted(3)
|
||||
if isdir('/run/systemd/system/'):
|
||||
self._systemd = Gio.DBusProxy.new_sync(session,
|
||||
Gio.DBusProxyFlags.NONE,
|
||||
None,
|
||||
"org.freedesktop.systemd1",
|
||||
"/org/freedesktop/systemd1",
|
||||
"org.freedesktop.systemd1.Manager", None)
|
||||
|
||||
def _open_terminal(self, path):
|
||||
def open_in_ghostty_activated(_menu, paths):
|
||||
for path in paths:
|
||||
cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false']
|
||||
child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
|
||||
if self._systemd:
|
||||
# Move new terminal into a dedicated systemd scope to make systemd
|
||||
# track the terminal separately; in particular this makes systemd
|
||||
# keep a separate CPU and memory account for the terminal which in turn
|
||||
# ensures that oomd doesn't take nautilus down if a process in
|
||||
# ghostty consumes a lot of memory.
|
||||
pid = int(child.get_identifier())
|
||||
props = [("PIDs", GLib.Variant('au', [pid])),
|
||||
('CollectMode', GLib.Variant('s', 'inactive-or-failed'))]
|
||||
name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid)
|
||||
args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, []))
|
||||
self._systemd.call_sync('StartTransientUnit', args,
|
||||
Gio.DBusCallFlags.NO_AUTO_START, 500, None)
|
||||
Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE)
|
||||
|
||||
def _menu_item_activated(self, _menu, paths):
|
||||
for path in paths:
|
||||
self._open_terminal(path)
|
||||
|
||||
def _make_item(self, name, paths):
|
||||
def get_paths_to_open(files):
|
||||
paths = []
|
||||
for file in files:
|
||||
location = file.get_location() if file.is_directory() else file.get_parent_location()
|
||||
path = location.get_path()
|
||||
if path and path not in paths:
|
||||
paths.append(path)
|
||||
if 10 < len(paths):
|
||||
# Let's not open anything if the user selected a lot of directories,
|
||||
# to avoid accidentally spamming their desktop with dozends of
|
||||
# new windows or tabs. Ten is a totally arbitrary limit :)
|
||||
return []
|
||||
else:
|
||||
return paths
|
||||
|
||||
|
||||
def get_items_for_files(name, files):
|
||||
paths = get_paths_to_open(files)
|
||||
if paths:
|
||||
item = Nautilus.MenuItem(name=name, label=_('Open in Ghostty'),
|
||||
icon='com.mitchellh.ghostty')
|
||||
item.connect('activate', self._menu_item_activated, paths)
|
||||
return item
|
||||
item.connect('activate', open_in_ghostty_activated, paths)
|
||||
return [item]
|
||||
else:
|
||||
return []
|
||||
|
||||
def _paths_to_open(self, files):
|
||||
paths = []
|
||||
for file in files:
|
||||
location = file.get_location() if file.is_directory() else file.get_parent_location()
|
||||
path = location.get_path()
|
||||
if path and path not in paths:
|
||||
paths.append(path)
|
||||
if 10 < len(paths):
|
||||
# Let's not open anything if the user selected a lot of directories,
|
||||
# to avoid accidentally spamming their desktop with dozends of
|
||||
# new windows or tabs. Ten is a totally arbitrary limit :)
|
||||
return []
|
||||
else:
|
||||
return paths
|
||||
|
||||
def get_file_items(self, *args):
|
||||
# Nautilus 3.0 API passes args (window, files), 4.0 API just passes files
|
||||
files = args[0] if len(args) == 1 else args[1]
|
||||
paths = self._paths_to_open(files)
|
||||
if paths:
|
||||
return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)]
|
||||
else:
|
||||
return []
|
||||
class GhosttyMenuProvider(GObject.GObject, Nautilus.MenuProvider):
|
||||
def get_file_items(self, files):
|
||||
return get_items_for_files('GhosttyNautilus::open_in_ghostty', files)
|
||||
|
||||
def get_background_items(self, *args):
|
||||
# Nautilus 3.0 API passes args (window, file), 4.0 API just passes file
|
||||
file = args[0] if len(args) == 1 else args[1]
|
||||
paths = self._paths_to_open([file])
|
||||
if paths:
|
||||
return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)]
|
||||
else:
|
||||
return []
|
||||
def get_background_items(self, file):
|
||||
return get_items_for_files('GhosttyNautilus::open_folder_in_ghostty', [file])
|
||||
|
||||
23
flake.lock
generated
23
flake.lock
generated
@@ -41,27 +41,26 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755776884,
|
||||
"narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=",
|
||||
"lastModified": 1768068402,
|
||||
"narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86",
|
||||
"rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "release-25.05",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763191728,
|
||||
"narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=",
|
||||
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -126,17 +125,17 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1758405547,
|
||||
"narHash": "sha256-WgaDgvIZMPvlZcZrpPMjkaalTBnGF2lTG+62znXctWM=",
|
||||
"lastModified": 1768231828,
|
||||
"narHash": "sha256-wL/8Iij4T2OLkhHcc4NieOjf7YeJffaUYbCiCqKv/+0=",
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
};
|
||||
|
||||
zon2nix = {
|
||||
url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245";
|
||||
url = "github:jcollie/zon2nix?rev=c28e93f3ba133d4c1b1d65224e2eebede61fd071";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager?ref=release-25.05";
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
@@ -117,7 +117,6 @@
|
||||
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
|
||||
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
|
||||
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
|
||||
x11-gnome = runVM ./nix/vm/x11-gnome.nix;
|
||||
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
|
||||
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
|
||||
"sha256": "8bfec500e00926f679853ee23d67cc392d3c3181733ca4704738651d3f70baa3"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -55,15 +61,15 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
"sha256": "a05fd01e04cf11ab781e28387c621d2e420f1e6044c8e27a25e603ea99ef7860"
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
|
||||
"sha256": "c816c20e8c75f3e15ae867350e79925502d1a6a85938bb1a73b8927e5f31f9cb"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
|
||||
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
|
||||
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
|
||||
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
|
||||
"dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
|
||||
"sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
@@ -169,9 +175,9 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz",
|
||||
"dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T",
|
||||
"sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10"
|
||||
"url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz",
|
||||
"dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||
"sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
|
||||
@@ -66,6 +66,14 @@ typedef enum {
|
||||
GHOSTTY_MOUSE_LEFT,
|
||||
GHOSTTY_MOUSE_RIGHT,
|
||||
GHOSTTY_MOUSE_MIDDLE,
|
||||
GHOSTTY_MOUSE_FOUR,
|
||||
GHOSTTY_MOUSE_FIVE,
|
||||
GHOSTTY_MOUSE_SIX,
|
||||
GHOSTTY_MOUSE_SEVEN,
|
||||
GHOSTTY_MOUSE_EIGHT,
|
||||
GHOSTTY_MOUSE_NINE,
|
||||
GHOSTTY_MOUSE_TEN,
|
||||
GHOSTTY_MOUSE_ELEVEN,
|
||||
} ghostty_input_mouse_button_e;
|
||||
|
||||
typedef enum {
|
||||
@@ -102,6 +110,13 @@ typedef enum {
|
||||
GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
|
||||
} ghostty_input_mods_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
|
||||
GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
|
||||
GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
|
||||
GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
|
||||
} ghostty_binding_flags_e;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_RELEASE,
|
||||
GHOSTTY_ACTION_PRESS,
|
||||
@@ -317,12 +332,14 @@ typedef struct {
|
||||
typedef enum {
|
||||
GHOSTTY_TRIGGER_PHYSICAL,
|
||||
GHOSTTY_TRIGGER_UNICODE,
|
||||
GHOSTTY_TRIGGER_CATCH_ALL,
|
||||
} ghostty_input_trigger_tag_e;
|
||||
|
||||
typedef union {
|
||||
ghostty_input_key_e translated;
|
||||
ghostty_input_key_e physical;
|
||||
uint32_t unicode;
|
||||
// catch_all has no payload
|
||||
} ghostty_input_trigger_key_u;
|
||||
|
||||
typedef struct {
|
||||
@@ -414,6 +431,12 @@ typedef union {
|
||||
ghostty_platform_ios_s ios;
|
||||
} ghostty_platform_u;
|
||||
|
||||
typedef enum {
|
||||
GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
|
||||
GHOSTTY_SURFACE_CONTEXT_TAB = 1,
|
||||
GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
|
||||
} ghostty_surface_context_e;
|
||||
|
||||
typedef struct {
|
||||
ghostty_platform_e platform_tag;
|
||||
ghostty_platform_u platform;
|
||||
@@ -426,6 +449,7 @@ typedef struct {
|
||||
size_t env_var_count;
|
||||
const char* initial_input;
|
||||
bool wait_after_command;
|
||||
ghostty_surface_context_e context;
|
||||
} ghostty_surface_config_s;
|
||||
|
||||
typedef struct {
|
||||
@@ -452,6 +476,12 @@ typedef struct {
|
||||
size_t len;
|
||||
} ghostty_config_color_list_s;
|
||||
|
||||
// config.RepeatableCommand
|
||||
typedef struct {
|
||||
const ghostty_command_s* commands;
|
||||
size_t len;
|
||||
} ghostty_config_command_list_s;
|
||||
|
||||
// config.Palette
|
||||
typedef struct {
|
||||
ghostty_config_color_s colors[256];
|
||||
@@ -689,6 +719,27 @@ typedef struct {
|
||||
ghostty_input_trigger_s trigger;
|
||||
} ghostty_action_key_sequence_s;
|
||||
|
||||
// apprt.action.KeyTable.Tag
|
||||
typedef enum {
|
||||
GHOSTTY_KEY_TABLE_ACTIVATE,
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE,
|
||||
GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
|
||||
} ghostty_action_key_table_tag_e;
|
||||
|
||||
// apprt.action.KeyTable.CValue
|
||||
typedef union {
|
||||
struct {
|
||||
const char *name;
|
||||
size_t len;
|
||||
} activate;
|
||||
} ghostty_action_key_table_u;
|
||||
|
||||
// apprt.action.KeyTable.C
|
||||
typedef struct {
|
||||
ghostty_action_key_table_tag_e tag;
|
||||
ghostty_action_key_table_u value;
|
||||
} ghostty_action_key_table_s;
|
||||
|
||||
// apprt.action.ColorKind
|
||||
typedef enum {
|
||||
GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
|
||||
@@ -834,6 +885,7 @@ typedef enum {
|
||||
GHOSTTY_ACTION_FLOAT_WINDOW,
|
||||
GHOSTTY_ACTION_SECURE_INPUT,
|
||||
GHOSTTY_ACTION_KEY_SEQUENCE,
|
||||
GHOSTTY_ACTION_KEY_TABLE,
|
||||
GHOSTTY_ACTION_COLOR_CHANGE,
|
||||
GHOSTTY_ACTION_RELOAD_CONFIG,
|
||||
GHOSTTY_ACTION_CONFIG_CHANGE,
|
||||
@@ -852,7 +904,7 @@ typedef enum {
|
||||
GHOSTTY_ACTION_SEARCH_TOTAL,
|
||||
GHOSTTY_ACTION_SEARCH_SELECTED,
|
||||
GHOSTTY_ACTION_READONLY,
|
||||
} ghostty_action_tag_e;
|
||||
} ghostty_action_tag_e;
|
||||
|
||||
typedef union {
|
||||
ghostty_action_split_direction_e new_split;
|
||||
@@ -879,6 +931,7 @@ typedef union {
|
||||
ghostty_action_float_window_e float_window;
|
||||
ghostty_action_secure_input_e secure_input;
|
||||
ghostty_action_key_sequence_s key_sequence;
|
||||
ghostty_action_key_table_s key_table;
|
||||
ghostty_action_color_change_s color_change;
|
||||
ghostty_action_reload_config_s reload_config;
|
||||
ghostty_action_config_change_s config_change;
|
||||
@@ -971,6 +1024,7 @@ ghostty_config_t ghostty_config_new();
|
||||
void ghostty_config_free(ghostty_config_t);
|
||||
ghostty_config_t ghostty_config_clone(ghostty_config_t);
|
||||
void ghostty_config_load_cli_args(ghostty_config_t);
|
||||
void ghostty_config_load_file(ghostty_config_t, const char*);
|
||||
void ghostty_config_load_default_files(ghostty_config_t);
|
||||
void ghostty_config_load_recursive_files(ghostty_config_t);
|
||||
void ghostty_config_finalize(ghostty_config_t);
|
||||
@@ -1004,7 +1058,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t,
|
||||
void ghostty_surface_free(ghostty_surface_t);
|
||||
void* ghostty_surface_userdata(ghostty_surface_t);
|
||||
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
|
||||
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
|
||||
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
|
||||
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
|
||||
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
|
||||
bool ghostty_surface_process_exited(ghostty_surface_t);
|
||||
@@ -1019,9 +1073,10 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
|
||||
ghostty_color_scheme_e);
|
||||
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
|
||||
ghostty_input_mods_e);
|
||||
void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*);
|
||||
bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
|
||||
bool ghostty_surface_key_is_binding(ghostty_surface_t,
|
||||
ghostty_input_key_s,
|
||||
ghostty_binding_flags_e*);
|
||||
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
|
||||
void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
|
||||
bool ghostty_surface_mouse_captured(ghostty_surface_t);
|
||||
|
||||
@@ -100,5 +100,20 @@
|
||||
<false/>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=</string>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.mitchellh.ghosttySurfaceId</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Ghostty Surface Identifier</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
|
||||
remoteInfo = Ghostty;
|
||||
};
|
||||
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
|
||||
@@ -42,6 +49,7 @@
|
||||
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
|
||||
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
|
||||
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
|
||||
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
|
||||
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = "<group>"; };
|
||||
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
|
||||
@@ -66,11 +74,13 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/macOS/AppDelegate.swift,
|
||||
"App/macOS/AppDelegate+Ghostty.swift",
|
||||
App/macOS/main.swift,
|
||||
App/macOS/MainMenu.xib,
|
||||
Features/About/About.xib,
|
||||
Features/About/AboutController.swift,
|
||||
Features/About/AboutView.swift,
|
||||
Features/About/CyclingIconView.swift,
|
||||
"Features/App Intents/CloseTerminalIntent.swift",
|
||||
"Features/App Intents/CommandPaletteIntent.swift",
|
||||
"Features/App Intents/Entities/CommandEntity.swift",
|
||||
@@ -118,6 +128,7 @@
|
||||
Features/Terminal/TerminalRestorable.swift,
|
||||
Features/Terminal/TerminalTabColor.swift,
|
||||
Features/Terminal/TerminalView.swift,
|
||||
Features/Terminal/TerminalViewContainer.swift,
|
||||
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
|
||||
"Features/Terminal/Window Styles/Terminal.xib",
|
||||
"Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib",
|
||||
@@ -137,19 +148,19 @@
|
||||
Features/Update/UpdateSimulator.swift,
|
||||
Features/Update/UpdateViewModel.swift,
|
||||
"Ghostty/FullscreenMode+Extension.swift",
|
||||
Ghostty/Ghostty.Command.swift,
|
||||
Ghostty/Ghostty.Error.swift,
|
||||
Ghostty/Ghostty.Event.swift,
|
||||
Ghostty/Ghostty.Input.swift,
|
||||
Ghostty/Ghostty.Surface.swift,
|
||||
Ghostty/InspectorView.swift,
|
||||
"Ghostty/NSEvent+Extension.swift",
|
||||
Ghostty/SurfaceScrollView.swift,
|
||||
Ghostty/SurfaceView_AppKit.swift,
|
||||
"Ghostty/Surface View/InspectorView.swift",
|
||||
"Ghostty/Surface View/SurfaceDragSource.swift",
|
||||
"Ghostty/Surface View/SurfaceGrabHandle.swift",
|
||||
"Ghostty/Surface View/SurfaceScrollView.swift",
|
||||
"Ghostty/Surface View/SurfaceView_AppKit.swift",
|
||||
Helpers/AppInfo.swift,
|
||||
Helpers/CodableBridge.swift,
|
||||
Helpers/Cursor.swift,
|
||||
Helpers/DraggableWindowView.swift,
|
||||
Helpers/ExpiringUndoManager.swift,
|
||||
"Helpers/Extensions/Double+Extension.swift",
|
||||
"Helpers/Extensions/EventModifiers+Extension.swift",
|
||||
@@ -166,6 +177,7 @@
|
||||
"Helpers/Extensions/NSView+Extension.swift",
|
||||
"Helpers/Extensions/NSWindow+Extension.swift",
|
||||
"Helpers/Extensions/NSWorkspace+Extension.swift",
|
||||
"Helpers/Extensions/Transferable+Extension.swift",
|
||||
"Helpers/Extensions/UndoManager+Extension.swift",
|
||||
"Helpers/Extensions/View+Extension.swift",
|
||||
Helpers/Fullscreen.swift,
|
||||
@@ -187,18 +199,26 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/iOS/iOSApp.swift,
|
||||
Ghostty/SurfaceView_UIKit.swift,
|
||||
"Ghostty/Surface View/SurfaceView_UIKit.swift",
|
||||
);
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = "<group>"; };
|
||||
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
|
||||
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
810ACC9C2E9D3301004F8F92 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -255,6 +275,7 @@
|
||||
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
|
||||
81F82BC72E82815D001EDFA7 /* Sources */,
|
||||
A54F45F42E1F047A0046BD5C /* Tests */,
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
|
||||
A5D495A3299BECBA00DD1313 /* Frameworks */,
|
||||
A5A1F8862A489D7400D1E8BC /* Resources */,
|
||||
A5B30532299BEAAA0047F10C /* Products */,
|
||||
@@ -267,6 +288,7 @@
|
||||
A5B30531299BEAAA0047F10C /* Ghostty.app */,
|
||||
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
|
||||
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
|
||||
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -283,6 +305,29 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */;
|
||||
buildPhases = (
|
||||
810ACC9B2E9D3301004F8F92 /* Sources */,
|
||||
810ACC9C2E9D3301004F8F92 /* Frameworks */,
|
||||
810ACC9D2E9D3301004F8F92 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
810ACCA02E9D3302004F8F92 /* GhosttyUITests */,
|
||||
);
|
||||
name = GhosttyUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = GhosttyUITests;
|
||||
productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
|
||||
@@ -356,9 +401,13 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 1610;
|
||||
TargetAttributes = {
|
||||
810ACC9E2E9D3301004F8F92 = {
|
||||
CreatedOnToolsVersion = 26.1;
|
||||
TestTargetID = A5B30530299BEAAA0047F10C;
|
||||
};
|
||||
A54F45F22E1F047A0046BD5C = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = A5B30530299BEAAA0047F10C;
|
||||
@@ -391,11 +440,19 @@
|
||||
A5B30530299BEAAA0047F10C /* Ghostty */,
|
||||
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
|
||||
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
|
||||
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
810ACC9D2E9D3301004F8F92 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45F12E1F047A0046BD5C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -434,6 +491,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
810ACC9B2E9D3301004F8F92 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A54F45EF2E1F047A0046BD5C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -458,6 +522,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
810ACCA62E9D3302004F8F92 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */;
|
||||
};
|
||||
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A5B30530299BEAAA0047F10C /* Ghostty */;
|
||||
@@ -575,6 +644,73 @@
|
||||
};
|
||||
name = ReleaseLocal;
|
||||
};
|
||||
810ACCA82E9D3302004F8F92 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
810ACCA92E9D3302004F8F92 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = Ghostty;
|
||||
};
|
||||
name = ReleaseLocal;
|
||||
};
|
||||
A54F45F92E1F047A0046BD5C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -991,6 +1127,16 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
810ACCA82E9D3302004F8F92 /* Debug */,
|
||||
810ACCA92E9D3302004F8F92 /* Release */,
|
||||
810ACCAA2E9D3302004F8F92 /* ReleaseLocal */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = ReleaseLocal;
|
||||
};
|
||||
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
ReferencedContainer = "container:Ghostty.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "810ACC9E2E9D3301004F8F92"
|
||||
BuildableName = "GhosttyUITests.xctest"
|
||||
BlueprintName = "GhosttyUITests"
|
||||
ReferencedContainer = "container:Ghostty.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
||||
34
macos/GhosttyUITests/AppKitExtensions.swift
Normal file
34
macos/GhosttyUITests/AppKitExtensions.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// AppKitExtensions.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 27.10.2025.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
extension NSColor {
|
||||
var isLightColor: Bool {
|
||||
return self.luminance > 0.5
|
||||
}
|
||||
|
||||
var luminance: Double {
|
||||
var r: CGFloat = 0
|
||||
var g: CGFloat = 0
|
||||
var b: CGFloat = 0
|
||||
var a: CGFloat = 0
|
||||
|
||||
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }
|
||||
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (0.299 * r) + (0.587 * g) + (0.114 * b)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSImage {
|
||||
func colorAt(x: Int, y: Int) -> NSColor? {
|
||||
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
return NSBitmapImageRep(cgImage: cgImage).colorAt(x: x, y: y)
|
||||
}
|
||||
}
|
||||
59
macos/GhosttyUITests/GhosttyCustomConfigCase.swift
Normal file
59
macos/GhosttyUITests/GhosttyCustomConfigCase.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// GhosttyCustomConfigCase.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 16.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
class GhosttyCustomConfigCase: XCTestCase {
|
||||
/// We only want run these UI tests
|
||||
/// when testing manually with Xcode IDE
|
||||
///
|
||||
/// So that we don't have to wait for each ci check
|
||||
/// to run these tedious tests
|
||||
override class var defaultTestSuite: XCTestSuite {
|
||||
// https://lldb.llvm.org/cpp_reference/PlatformDarwin_8cpp_source.html#:~:text==%20%22-,IDE_DISABLED_OS_ACTIVITY_DT_MODE
|
||||
|
||||
if ProcessInfo.processInfo.environment["IDE_DISABLED_OS_ACTIVITY_DT_MODE"] != nil {
|
||||
return XCTestSuite(forTestCaseClass: Self.self)
|
||||
} else {
|
||||
return XCTestSuite(name: "Skipping \(className())")
|
||||
}
|
||||
}
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var configFile: URL?
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
if let configFile {
|
||||
try FileManager.default.removeItem(at: configFile)
|
||||
}
|
||||
}
|
||||
|
||||
func updateConfig(_ newConfig: String) throws {
|
||||
if configFile == nil {
|
||||
let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("ghostty")
|
||||
configFile = temporaryConfig
|
||||
}
|
||||
try newConfig.write(to: configFile!, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
func ghosttyApplication() throws -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"])
|
||||
guard let configFile else {
|
||||
return app
|
||||
}
|
||||
app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path
|
||||
return app
|
||||
}
|
||||
}
|
||||
159
macos/GhosttyUITests/GhosttyThemeTests.swift
Normal file
159
macos/GhosttyUITests/GhosttyThemeTests.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// GhosttyThemeTests.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 27.10.2025.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import XCTest
|
||||
|
||||
final class GhosttyThemeTests: GhosttyCustomConfigCase {
|
||||
let windowTitle = "GhosttyThemeTests"
|
||||
private func assertTitlebarAppearance(
|
||||
_ appearance: XCUIDevice.Appearance,
|
||||
for app: XCUIApplication,
|
||||
title: String? = nil,
|
||||
colorLocation: CGPoint? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws {
|
||||
for i in 0 ..< app.windows.count {
|
||||
let titleView = app.windows.element(boundBy: i).staticTexts.element(matching: NSPredicate(format: "value == '\(title ?? windowTitle)'"))
|
||||
|
||||
let image = titleView.screenshot().image
|
||||
guard let imageColor = image.colorAt(x: Int(colorLocation?.x ?? 1), y: Int(colorLocation?.y ?? 1)) else {
|
||||
throw XCTSkip("failed to get pixel color", file: file, line: line)
|
||||
}
|
||||
|
||||
switch appearance {
|
||||
case .dark:
|
||||
XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test", file: file, line: line)
|
||||
default:
|
||||
XCTAssertGreaterThanOrEqual(imageColor.luminance, 0.5, "Expected light appearance for this test", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// https://github.com/ghostty-org/ghostty/issues/8282
|
||||
@MainActor
|
||||
func testIssue8282() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
// create a split
|
||||
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
// create a new window
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLightTransparentWindowThemeWithDarkTerminal() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n window-theme=light")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLightNativeWindowThemeWithDarkTerminal() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n window-theme = light \n macos-titlebar-style = native")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadingLightTransparentWindowTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// default dark theme
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme = light")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSwitchingSystemTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
XCUIDevice.shared.appearance = .light
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadFromLightWindowThemeToDefaultTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadFromDefaultThemeToDarkWindowTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n ")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReloadingFromDarkThemeToSystemLightTheme() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n window-theme=dark")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
try assertTitlebarAppearance(.dark, for: app)
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night")
|
||||
// reload config
|
||||
app.typeKey(",", modifierFlags: [.command, .shift])
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.light, for: app)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testQuickTerminalThemeChange() async throws {
|
||||
try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night \n confirm-close-surface=false")
|
||||
XCUIDevice.shared.appearance = .light
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// close default window
|
||||
app.typeKey("w", modifierFlags: [.command])
|
||||
// open quick terminal
|
||||
app.menuBarItems["View"].firstMatch.click()
|
||||
app.menuItems["Quick Terminal"].firstMatch.click()
|
||||
let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development."
|
||||
try assertTitlebarAppearance(.light, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5)) // to avoid dark edge
|
||||
XCUIDevice.shared.appearance = .dark
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
try assertTitlebarAppearance(.dark, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5))
|
||||
}
|
||||
}
|
||||
23
macos/GhosttyUITests/GhosttyTitleUITests.swift
Normal file
23
macos/GhosttyUITests/GhosttyTitleUITests.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// GhosttyTitleUITests.swift
|
||||
// GhosttyUITests
|
||||
//
|
||||
// Created by luca on 13.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class GhosttyTitleUITests: GhosttyCustomConfigCase {
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
try updateConfig(#"title = "GhosttyUITestsLaunchTests""#)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTitle() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
|
||||
XCTAssertEqual(app.windows.firstMatch.title, "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!")
|
||||
}
|
||||
}
|
||||
143
macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift
Normal file
143
macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// GhosttyTitlebarTabsUITests.swift
|
||||
// Ghostty
|
||||
//
|
||||
// Created by luca on 16.10.2025.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase {
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
|
||||
try updateConfig(
|
||||
"""
|
||||
macos-titlebar-style = tabs
|
||||
title = "GhosttyTitlebarTabsUITests"
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCustomTitlebar() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
// create a split
|
||||
app.groups["Terminal pane"].typeKey("d", modifierFlags: .command)
|
||||
app.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
let resetZoomButton = app.groups.buttons["ResetZoom"]
|
||||
let windowTitle = app.windows.firstMatch.title
|
||||
let titleView = app.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'"))
|
||||
|
||||
XCTAssertEqual(titleView.frame.midY, resetZoomButton.frame.midY, accuracy: 1, "Window title should be vertically centered with reset zoom button: \(titleView.frame.midY) != \(resetZoomButton.frame.midY)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryInNormalWindow() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryInFullscreen() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
app.typeKey("f", modifierFlags: [.command, .control])
|
||||
// using app to type ⌘+t might not be able to create tabs
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryAfterMovingTabs() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
|
||||
// create another 2 tabs
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
app.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
|
||||
// move to the left
|
||||
app.menuItems["_zoomLeft:"].firstMatch.click()
|
||||
|
||||
// create another window with 2 tabs
|
||||
app.windows.firstMatch.groups["Terminal pane"].typeKey("n", modifierFlags: .command)
|
||||
XCTAssertEqual(app.windows.count, 2, "There should be 2 windows")
|
||||
|
||||
// move to the right
|
||||
app.menuItems["_zoomRight:"].firstMatch.click()
|
||||
|
||||
// now second window is the first/main one in the list
|
||||
app.windows.firstMatch.groups["Terminal pane"].typeKey("t", modifierFlags: .command)
|
||||
|
||||
app.windows.element(boundBy: 1).tabs.firstMatch.click() // focus first window
|
||||
|
||||
// now the first window is the main one
|
||||
let firstTabInFirstWindow = app.windows.firstMatch.tabs.firstMatch
|
||||
let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.firstMatch
|
||||
|
||||
// drag a tab from one window to another
|
||||
firstTabInFirstWindow.press(forDuration: 0.2, thenDragTo: firstTabInSecondWindow)
|
||||
|
||||
// check tabs in the first
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
// focus another window
|
||||
app.windows.element(boundBy: 1).tabs.firstMatch.click()
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabsGeometryAfterMergingAllWindows() throws {
|
||||
let app = try ghosttyApplication()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist")
|
||||
|
||||
// create another 2 windows
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
|
||||
// merge into one window, resulting 3 tabs
|
||||
app.menuItems["mergeAllWindows:"].firstMatch.click()
|
||||
|
||||
XCTAssertTrue(app.wait(for: \.tabs.count, toEqual: 3, timeout: 1), "There should be 3 tabs")
|
||||
checkTabsGeometry(app.windows.firstMatch)
|
||||
}
|
||||
|
||||
func checkTabsGeometry(_ window: XCUIElement) {
|
||||
let closeTabButtons = window.buttons.matching(identifier: "_closeButton")
|
||||
|
||||
XCTAssertEqual(closeTabButtons.count, window.tabs.count, "Close tab buttons count should match tabs count")
|
||||
|
||||
var previousTabHeight: CGFloat?
|
||||
for idx in 0 ..< window.tabs.count {
|
||||
let currentTab = window.tabs.element(boundBy: idx)
|
||||
// focus
|
||||
currentTab.click()
|
||||
// switch to the tab
|
||||
window.typeKey("\(idx + 1)", modifierFlags: .command)
|
||||
// add a split
|
||||
window.typeKey("d", modifierFlags: .command)
|
||||
// zoom this split
|
||||
// haven't found a way to locate our reset zoom button yet..
|
||||
window.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
window.typeKey("\n", modifierFlags: [.command, .shift])
|
||||
|
||||
if let previousHeight = previousTabHeight {
|
||||
XCTAssertEqual(currentTab.frame.height, previousHeight, accuracy: 1, "The tab's height should stay the same")
|
||||
}
|
||||
previousTabHeight = currentTab.frame.height
|
||||
|
||||
let titleFrame = currentTab.frame
|
||||
let shortcutLabelFrame = window.staticTexts.element(matching: NSPredicate(format: "value CONTAINS[c] '⌘\(idx + 1)'")).firstMatch.frame
|
||||
let closeButtonFrame = closeTabButtons.element(boundBy: idx).frame
|
||||
|
||||
XCTAssertEqual(titleFrame.midY, shortcutLabelFrame.midY, accuracy: 1, "Tab title should be vertically centered with its shortcut label: \(titleFrame.midY) != \(shortcutLabelFrame.midY)")
|
||||
XCTAssertEqual(titleFrame.midY, closeButtonFrame.midY, accuracy: 1, "Tab title should be vertically centered with its close button: \(titleFrame.midY) != \(closeButtonFrame.midY)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
@main
|
||||
struct Ghostty_iOSApp: App {
|
||||
@StateObject private var ghostty_app = Ghostty.App()
|
||||
@StateObject private var ghostty_app: Ghostty.App
|
||||
|
||||
init() {
|
||||
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
|
||||
preconditionFailure("Initialize ghostty backend failed")
|
||||
}
|
||||
_ghostty_app = StateObject(wrappedValue: Ghostty.App())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
|
||||
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: Ghostty Delegate
|
||||
|
||||
/// This implements the Ghostty app delegate protocol which is used by the Ghostty
|
||||
/// APIs for app-global information.
|
||||
extension AppDelegate: Ghostty.Delegate {
|
||||
func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? {
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else {
|
||||
continue
|
||||
}
|
||||
|
||||
for surface in controller.surfaceTree {
|
||||
if surface.id == id {
|
||||
return surface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class AppDelegate: NSObject,
|
||||
@IBOutlet private var menuSelectAll: NSMenuItem?
|
||||
@IBOutlet private var menuFindParent: NSMenuItem?
|
||||
@IBOutlet private var menuFind: NSMenuItem?
|
||||
@IBOutlet private var menuSelectionForFind: NSMenuItem?
|
||||
@IBOutlet private var menuScrollToSelection: NSMenuItem?
|
||||
@IBOutlet private var menuFindNext: NSMenuItem?
|
||||
@IBOutlet private var menuFindPrevious: NSMenuItem?
|
||||
@IBOutlet private var menuHideFindBar: NSMenuItem?
|
||||
@@ -94,7 +96,7 @@ class AppDelegate: NSObject,
|
||||
private var derivedConfig: DerivedConfig = DerivedConfig()
|
||||
|
||||
/// The ghostty global state. Only one per process.
|
||||
let ghostty: Ghostty.App = Ghostty.App()
|
||||
let ghostty: Ghostty.App
|
||||
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = ExpiringUndoManager()
|
||||
@@ -153,6 +155,11 @@ class AppDelegate: NSObject,
|
||||
@Published private(set) var appIcon: NSImage? = nil
|
||||
|
||||
override init() {
|
||||
#if DEBUG
|
||||
ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"])
|
||||
#else
|
||||
ghostty = Ghostty.App()
|
||||
#endif
|
||||
super.init()
|
||||
|
||||
ghostty.delegate = self
|
||||
@@ -615,6 +622,8 @@ class AppDelegate: NSObject,
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
|
||||
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
|
||||
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
|
||||
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
|
||||
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
|
||||
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)
|
||||
|
||||
@@ -942,33 +951,8 @@ class AppDelegate: NSObject,
|
||||
var appIconName: String? = config.macosIcon.rawValue
|
||||
|
||||
switch (config.macosIcon) {
|
||||
case .official:
|
||||
// Discard saved icon name
|
||||
appIconName = nil
|
||||
break
|
||||
case .blueprint:
|
||||
appIcon = NSImage(named: "BlueprintImage")!
|
||||
|
||||
case .chalkboard:
|
||||
appIcon = NSImage(named: "ChalkboardImage")!
|
||||
|
||||
case .glass:
|
||||
appIcon = NSImage(named: "GlassImage")!
|
||||
|
||||
case .holographic:
|
||||
appIcon = NSImage(named: "HolographicImage")!
|
||||
|
||||
case .microchip:
|
||||
appIcon = NSImage(named: "MicrochipImage")!
|
||||
|
||||
case .paper:
|
||||
appIcon = NSImage(named: "PaperImage")!
|
||||
|
||||
case .retro:
|
||||
appIcon = NSImage(named: "RetroImage")!
|
||||
|
||||
case .xray:
|
||||
appIcon = NSImage(named: "XrayImage")!
|
||||
case let icon where icon.assetName != nil:
|
||||
appIcon = NSImage(named: icon.assetName!)!
|
||||
|
||||
case .custom:
|
||||
if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) {
|
||||
@@ -978,6 +962,7 @@ class AppDelegate: NSObject,
|
||||
appIcon = nil // Revert back to official icon if invalid location
|
||||
appIconName = nil // Discard saved icon name
|
||||
}
|
||||
|
||||
case .customStyle:
|
||||
// Discard saved icon name
|
||||
// if no valid colours were found
|
||||
@@ -993,6 +978,10 @@ class AppDelegate: NSObject,
|
||||
let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString)
|
||||
appIconName = (colorStrings + [config.macosIconFrame.rawValue])
|
||||
.joined(separator: "_")
|
||||
|
||||
default:
|
||||
// Discard saved icon name
|
||||
appIconName = nil
|
||||
}
|
||||
|
||||
// Only change the icon if it has actually changed from the current one,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
@@ -58,6 +58,7 @@
|
||||
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
|
||||
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
|
||||
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
|
||||
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
|
||||
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
|
||||
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
|
||||
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
|
||||
@@ -281,6 +282,19 @@
|
||||
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="2N8-Xz-RVc"/>
|
||||
<menuItem title="Use Selection for Find" id="TDN-42-Bu7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Jump to Selection" id="1rN-4k-Dz3">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="scrollToSelection:" target="-1" id="5gS-8h-Xm2"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
|
||||
@@ -44,10 +44,7 @@ struct AboutView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
ghosttyIconImage()
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 128)
|
||||
CyclingIconView()
|
||||
|
||||
VStack(alignment: .center, spacing: 32) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
|
||||
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
/// A view that cycles through Ghostty's official icon variants.
|
||||
struct CyclingIconView: View {
|
||||
@State private var currentIcon: Ghostty.MacOSIcon = .official
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
private let icons: [Ghostty.MacOSIcon] = [
|
||||
.official,
|
||||
.blueprint,
|
||||
.chalkboard,
|
||||
.microchip,
|
||||
.glass,
|
||||
.holographic,
|
||||
.paper,
|
||||
.retro,
|
||||
.xray,
|
||||
]
|
||||
private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
iconView(for: currentIcon)
|
||||
.id(currentIcon)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.5), value: currentIcon)
|
||||
.frame(height: 128)
|
||||
.onReceive(timerPublisher.autoconnect()) { _ in
|
||||
if !isHovering {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
}
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
.onTapGesture {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
.help("macos-icon = \(currentIcon.rawValue)")
|
||||
.accessibilityLabel("Ghostty Application Icon")
|
||||
.accessibilityHint("Click to cycle through icon variants")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iconView(for icon: Ghostty.MacOSIcon) -> some View {
|
||||
let iconImage: Image = switch icon.assetName {
|
||||
case let assetName?: Image(assetName)
|
||||
case nil: ghosttyIconImage()
|
||||
}
|
||||
|
||||
iconImage
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
|
||||
private func advanceToNextIcon() {
|
||||
let currentIndex = icons.firstIndex(of: currentIcon) ?? 0
|
||||
let nextIndex = icons.indexWrapping(after: currentIndex)
|
||||
currentIcon = icons[nextIndex]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppIntents
|
||||
import Cocoa
|
||||
|
||||
// MARK: AppEntity
|
||||
|
||||
@@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery {
|
||||
|
||||
@MainActor
|
||||
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
|
||||
let commands = appDelegate.ghostty.config.commandPaletteEntries
|
||||
|
||||
// Extract unique terminal IDs to avoid fetching duplicates
|
||||
let terminalIds = Set(identifiers.map(\.terminalId))
|
||||
let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
|
||||
|
||||
// Build a cache of terminals and their available commands
|
||||
// This avoids repeated command fetching for the same terminal
|
||||
typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
|
||||
let commandMap: [TerminalEntity.ID: Tuple] =
|
||||
// Build a lookup from terminal ID to terminal entity
|
||||
let terminalMap: [TerminalEntity.ID: TerminalEntity] =
|
||||
terminals.reduce(into: [:]) { result, terminal in
|
||||
guard let commands = try? terminal.surfaceModel?.commands() else { return }
|
||||
result[terminal.id] = (terminal: terminal, commands: commands)
|
||||
result[terminal.id] = terminal
|
||||
}
|
||||
|
||||
|
||||
// Map each identifier to its corresponding CommandEntity. If a command doesn't
|
||||
// exist it maps to nil and is removed via compactMap.
|
||||
return identifiers.compactMap { id in
|
||||
guard let (terminal, commands) = commandMap[id.terminalId],
|
||||
guard let terminal = terminalMap[id.terminalId],
|
||||
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery {
|
||||
|
||||
@MainActor
|
||||
func suggestedEntities() async throws -> [CommandEntity] {
|
||||
guard let terminal = commandPaletteIntent?.terminal,
|
||||
let surface = terminal.surfaceModel else { return [] }
|
||||
return try surface.commands().map { CommandEntity($0, for: terminal) }
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate,
|
||||
let terminal = commandPaletteIntent?.terminal else { return [] }
|
||||
return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +83,11 @@ struct TerminalCommandPaletteView: View {
|
||||
/// Commands for installing or canceling available updates.
|
||||
private var updateOptions: [CommandOption] {
|
||||
var options: [CommandOption] = []
|
||||
|
||||
|
||||
guard let updateViewModel, updateViewModel.state.isInstallable else {
|
||||
return options
|
||||
}
|
||||
|
||||
|
||||
// We override the update available one only because we want to properly
|
||||
// convey it'll go all the way through.
|
||||
let title: String
|
||||
@@ -96,7 +96,7 @@ struct TerminalCommandPaletteView: View {
|
||||
} else {
|
||||
title = updateViewModel.text
|
||||
}
|
||||
|
||||
|
||||
options.append(CommandOption(
|
||||
title: title,
|
||||
description: updateViewModel.description,
|
||||
@@ -106,32 +106,27 @@ struct TerminalCommandPaletteView: View {
|
||||
) {
|
||||
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
|
||||
})
|
||||
|
||||
|
||||
options.append(CommandOption(
|
||||
title: "Cancel or Skip Update",
|
||||
description: "Dismiss the current update process"
|
||||
) {
|
||||
updateViewModel.state.cancel()
|
||||
})
|
||||
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/// Commands exposed by the terminal surface.
|
||||
/// Custom commands from the command-palette-entry configuration.
|
||||
private var terminalOptions: [CommandOption] {
|
||||
guard let surface = surfaceView.surfaceModel else { return [] }
|
||||
do {
|
||||
return try surface.commands().map { c in
|
||||
CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList,
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
|
||||
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
|
||||
CommandOption(
|
||||
title: c.title,
|
||||
description: c.description
|
||||
) {
|
||||
onAction(c.action)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController {
|
||||
}
|
||||
|
||||
// Setup our content
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self
|
||||
))
|
||||
)
|
||||
|
||||
// Clear out our frame at this point, the fixup from above is complete.
|
||||
if let qtWindow = window as? QuickTerminalWindow {
|
||||
@@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
|
||||
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
||||
// Also check if the user has overridden transparency to be fully opaque.
|
||||
if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 {
|
||||
if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) {
|
||||
window.isOpaque = false
|
||||
|
||||
// This is weird, but we don't use ".clear" because this creates a look that
|
||||
@@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// Terminal.app more easily.
|
||||
window.backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
if !derivedConfig.backgroundBlur.isGlassStyle {
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
}
|
||||
} else {
|
||||
window.isOpaque = true
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
@@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
|
||||
let quickTerminalSize: QuickTerminalSize
|
||||
let backgroundOpacity: Double
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
|
||||
init() {
|
||||
self.quickTerminalScreen = .main
|
||||
@@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = .move
|
||||
self.quickTerminalSize = QuickTerminalSize()
|
||||
self.backgroundOpacity = 1.0
|
||||
self.backgroundBlur = .disabled
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
@@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
|
||||
self.quickTerminalSize = config.quickTerminalSize
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,10 +121,10 @@ extension SplitTree {
|
||||
|
||||
/// Insert a new view at the given view point by creating a split in the given direction.
|
||||
/// This will always reset the zoomed state of the tree.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
return .init(
|
||||
root: try root.insert(view: view, at: at, direction: direction),
|
||||
root: try root.inserting(view: view, at: at, direction: direction),
|
||||
zoomed: nil)
|
||||
}
|
||||
/// Find a node containing a view with the specified ID.
|
||||
@@ -137,7 +137,7 @@ extension SplitTree {
|
||||
|
||||
/// 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 {
|
||||
func removing(_ target: Node) -> Self {
|
||||
guard let root else { return self }
|
||||
|
||||
// If we're removing the root itself, return an empty tree
|
||||
@@ -155,7 +155,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
/// Replace a node in the tree with a new node.
|
||||
func replace(node: Node, with newNode: Node) throws -> Self {
|
||||
func replacing(node: Node, with newNode: Node) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
// Get the path to the node we want to replace
|
||||
@@ -164,7 +164,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
// Replace the node
|
||||
let newRoot = try root.replaceNode(at: path, with: newNode)
|
||||
let newRoot = try root.replacingNode(at: path, with: newNode)
|
||||
|
||||
// Update zoomed if it was the replaced node
|
||||
let newZoomed = (zoomed == node) ? newNode : zoomed
|
||||
@@ -232,7 +232,7 @@ extension SplitTree {
|
||||
|
||||
/// Equalize all splits in the tree so that each split's ratio is based on the
|
||||
/// relative weight (number of leaves) of its children.
|
||||
func equalize() -> Self {
|
||||
func equalized() -> Self {
|
||||
guard let root else { return self }
|
||||
let newRoot = root.equalize()
|
||||
return .init(root: newRoot, zoomed: zoomed)
|
||||
@@ -255,7 +255,7 @@ extension SplitTree {
|
||||
/// - bounds: The bounds used to construct the spatial tree representation
|
||||
/// - Returns: A new SplitTree with the adjusted split ratios
|
||||
/// - 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 {
|
||||
func resizing(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
|
||||
@@ -327,7 +327,7 @@ extension SplitTree {
|
||||
)
|
||||
|
||||
// Replace the split node with the new one
|
||||
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
|
||||
let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit))
|
||||
return .init(root: newRoot, zoomed: nil)
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ extension SplitTree.Node {
|
||||
///
|
||||
/// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
|
||||
/// maybe throw instead but at the moment we just do nothing.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
// Get the path to our insertion point. If it doesn't exist we do
|
||||
// nothing.
|
||||
guard let path = path(to: .leaf(view: at)) else {
|
||||
@@ -544,11 +544,11 @@ extension SplitTree.Node {
|
||||
))
|
||||
|
||||
// Replace the node at the path with the new split
|
||||
return try replaceNode(at: path, with: newSplit)
|
||||
return try replacingNode(at: path, with: newSplit)
|
||||
}
|
||||
|
||||
/// Helper function to replace a node at the given path from the root
|
||||
func replaceNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
func replacingNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
// If path is empty, replace the root
|
||||
if path.isEmpty {
|
||||
return newNode
|
||||
@@ -635,7 +635,7 @@ extension SplitTree.Node {
|
||||
/// Resize a split node to the specified ratio.
|
||||
/// For leaf nodes, this returns the node unchanged.
|
||||
/// For split nodes, this creates a new split with the updated ratio.
|
||||
func resize(to ratio: Double) -> Self {
|
||||
func resizing(to ratio: Double) -> Self {
|
||||
switch self {
|
||||
case .leaf:
|
||||
// Leaf nodes don't have a ratio to resize
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single operation within the split tree.
|
||||
///
|
||||
/// Rather than binding the split tree (which is immutable), any mutable operations are
|
||||
/// exposed via this enum to the embedder to handle.
|
||||
enum TerminalSplitOperation {
|
||||
case resize(Resize)
|
||||
case drop(Drop)
|
||||
|
||||
struct Resize {
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
let ratio: Double
|
||||
}
|
||||
|
||||
struct Drop {
|
||||
/// The surface being dragged.
|
||||
let payload: Ghostty.SurfaceView
|
||||
|
||||
/// The surface it was dragged onto
|
||||
let destination: Ghostty.SurfaceView
|
||||
|
||||
/// The zone it was dropped to determine how to split the destination.
|
||||
let zone: TerminalSplitDropZone
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitTreeView: View {
|
||||
let tree: SplitTree<Ghostty.SurfaceView>
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
if let node = tree.zoomed ?? tree.root {
|
||||
TerminalSplitSubtreeView(
|
||||
node: node,
|
||||
isRoot: node == tree.root,
|
||||
onResize: onResize)
|
||||
action: action)
|
||||
// This is necessary because we can't rely on SwiftUI's implicit
|
||||
// structural identity to detect changes to this view. Due to
|
||||
// the tree structure of splits it could result in bad behaviors.
|
||||
@@ -19,21 +44,17 @@ struct TerminalSplitTreeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitSubtreeView: View {
|
||||
fileprivate struct TerminalSplitSubtreeView: View {
|
||||
@EnvironmentObject var ghostty: Ghostty.App
|
||||
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
var isRoot: Bool = false
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
switch (node) {
|
||||
case .leaf(let leafView):
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: leafView,
|
||||
isSplit: !isRoot)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
|
||||
|
||||
case .split(let split):
|
||||
let splitViewDirection: SplitViewDirection = switch (split.direction) {
|
||||
@@ -46,15 +67,15 @@ struct TerminalSplitSubtreeView: View {
|
||||
.init(get: {
|
||||
CGFloat(split.ratio)
|
||||
}, set: {
|
||||
onResize(node, $0)
|
||||
action(.resize(.init(node: node, ratio: $0)))
|
||||
}),
|
||||
dividerColor: ghostty.config.splitDividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
left: {
|
||||
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.left, action: action)
|
||||
},
|
||||
right: {
|
||||
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.right, action: action)
|
||||
},
|
||||
onEqualize: {
|
||||
guard let surface = node.leftmostLeaf().surface else { return }
|
||||
@@ -64,3 +85,173 @@ struct TerminalSplitSubtreeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct TerminalSplitLeaf: View {
|
||||
let surfaceView: Ghostty.SurfaceView
|
||||
let isSplit: Bool
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
@State private var dropState: DropState = .idle
|
||||
@State private var isSelfDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: surfaceView,
|
||||
isSplit: isSplit)
|
||||
.background {
|
||||
// If we're dragging ourself, we hide the entire drop zone. This makes
|
||||
// it so that a released drop animates back to its source properly
|
||||
// so it is a proper invalid drop zone.
|
||||
if !isSelfDragging {
|
||||
Color.clear
|
||||
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
|
||||
dropState: $dropState,
|
||||
viewSize: geometry.size,
|
||||
destinationSurface: surfaceView,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if !isSelfDragging, case .dropping(let zone) = dropState {
|
||||
zone.overlay(in: geometry)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in
|
||||
isSelfDragging = value == surfaceView.id
|
||||
if isSelfDragging {
|
||||
dropState = .idle
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropState: Equatable {
|
||||
case idle
|
||||
case dropping(TerminalSplitDropZone)
|
||||
}
|
||||
|
||||
private struct SplitDropDelegate: DropDelegate {
|
||||
@Binding var dropState: DropState
|
||||
let viewSize: CGSize
|
||||
let destinationSurface: Ghostty.SurfaceView
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
info.hasItemsConforming(to: [.ghosttySurfaceId])
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
// For some reason dropUpdated is sent after performDrop is called
|
||||
// and we don't want to reset our drop zone to show it so we have
|
||||
// to guard on the state here.
|
||||
guard case .dropping = dropState else { return DropProposal(operation: .forbidden) }
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
dropState = .idle
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
|
||||
dropState = .idle
|
||||
|
||||
// Load the dropped surface asynchronously using Transferable
|
||||
let providers = info.itemProviders(for: [.ghosttySurfaceId])
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
// Capture action before the async closure
|
||||
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
|
||||
switch result {
|
||||
case .success(let sourceSurface):
|
||||
DispatchQueue.main.async {
|
||||
// Don't allow dropping on self
|
||||
guard let destinationSurface else { return }
|
||||
guard sourceSurface !== destinationSurface else { return }
|
||||
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
|
||||
}
|
||||
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalSplitDropZone: String, Equatable {
|
||||
case top
|
||||
case bottom
|
||||
case left
|
||||
case right
|
||||
|
||||
/// Determines which drop zone the cursor is in based on proximity to edges.
|
||||
///
|
||||
/// Divides the view into four triangular regions by drawing diagonals from
|
||||
/// corner to corner. The drop zone is determined by which edge the cursor
|
||||
/// is closest to, creating natural triangular hit regions for each side.
|
||||
static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone {
|
||||
let relX = point.x / size.width
|
||||
let relY = point.y / size.height
|
||||
|
||||
let distToLeft = relX
|
||||
let distToRight = 1 - relX
|
||||
let distToTop = relY
|
||||
let distToBottom = 1 - relY
|
||||
|
||||
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
|
||||
|
||||
if minDist == distToLeft { return .left }
|
||||
if minDist == distToRight { return .right }
|
||||
if minDist == distToTop { return .top }
|
||||
return .bottom
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func overlay(in geometry: GeometryProxy) -> some View {
|
||||
let overlayColor = Color.accentColor.opacity(0.3)
|
||||
|
||||
switch self {
|
||||
case .top:
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .bottom:
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
}
|
||||
case .left:
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .right:
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,11 @@ class BaseTerminalController: NSWindowController,
|
||||
selector: #selector(ghosttyDidPresentTerminal(_:)),
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttySurfaceDragEndedNoTarget(_:)),
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: nil)
|
||||
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
@@ -235,7 +240,7 @@ class BaseTerminalController: NSWindowController,
|
||||
// Do the split
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.insert(
|
||||
newTree = try surfaceTree.inserting(
|
||||
view: newView,
|
||||
at: oldView,
|
||||
direction: direction)
|
||||
@@ -445,14 +450,14 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
surfaceTree.remove(node),
|
||||
surfaceTree.removing(node),
|
||||
moveFocusTo: nextFocus,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Close Terminal"
|
||||
)
|
||||
}
|
||||
|
||||
private func replaceSurfaceTree(
|
||||
func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
@@ -466,33 +471,33 @@ class BaseTerminalController: NSWindowController,
|
||||
Ghostty.moveFocus(to: newView, from: oldView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager {
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
guard let undoManager else { return }
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,7 +614,7 @@ class BaseTerminalController: NSWindowController,
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Equalize the splits
|
||||
surfaceTree = surfaceTree.equalize()
|
||||
surfaceTree = surfaceTree.equalized()
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
|
||||
@@ -699,7 +704,7 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
// Perform the resize using the new SplitTree resize method
|
||||
do {
|
||||
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to resize split: \(error)")
|
||||
}
|
||||
@@ -721,6 +726,42 @@ class BaseTerminalController: NSWindowController,
|
||||
target.highlight()
|
||||
}
|
||||
|
||||
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// If our tree isn't split, then we never create a new window, because
|
||||
// it is already a single split.
|
||||
guard surfaceTree.isSplit else { return }
|
||||
|
||||
// If we are removing our focused surface then we move it. We need to
|
||||
// keep track of our old one so undo sends focus back to the right place.
|
||||
let oldFocusedSurface = focusedSurface
|
||||
if focusedSurface == target {
|
||||
focusedSurface = findNextFocusTargetAfterClosing(node: targetNode)
|
||||
}
|
||||
|
||||
// Remove the surface from our tree
|
||||
let removedTree = surfaceTree.removing(targetNode)
|
||||
|
||||
// Create a new tree with the dragged surface and open a new window
|
||||
let newTree = SplitTree<Ghostty.SurfaceView>(view: target)
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface)
|
||||
_ = TerminalController.newWindow(
|
||||
ghostty,
|
||||
tree: newTree,
|
||||
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
|
||||
confirmUndo: false)
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
||||
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
||||
@@ -793,7 +834,15 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
private func applyTitleToWindow() {
|
||||
guard let window else { return }
|
||||
window.title = titleOverride ?? lastComputedTitle
|
||||
|
||||
if let titleOverride {
|
||||
window.title = computeTitle(
|
||||
title: titleOverride,
|
||||
bell: focusedSurface?.bell ?? false)
|
||||
return
|
||||
}
|
||||
|
||||
window.title = lastComputedTitle
|
||||
}
|
||||
|
||||
func pwdDidChange(to: URL?) {
|
||||
@@ -817,14 +866,101 @@ class BaseTerminalController: NSWindowController,
|
||||
self.window?.contentResizeIncrements = to
|
||||
}
|
||||
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resize(to: newRatio)
|
||||
func performSplitAction(_ action: TerminalSplitOperation) {
|
||||
switch action {
|
||||
case .resize(let resize):
|
||||
splitDidResize(node: resize.node, to: resize.ratio)
|
||||
case .drop(let drop):
|
||||
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resizing(to: newRatio)
|
||||
do {
|
||||
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
|
||||
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidDrop(
|
||||
source: Ghostty.SurfaceView,
|
||||
destination: Ghostty.SurfaceView,
|
||||
zone: TerminalSplitDropZone
|
||||
) {
|
||||
// Map drop zone to split direction
|
||||
let direction: SplitTree<Ghostty.SurfaceView>.NewDirection = switch zone {
|
||||
case .top: .up
|
||||
case .bottom: .down
|
||||
case .left: .left
|
||||
case .right: .right
|
||||
}
|
||||
|
||||
// Check if source is in our tree
|
||||
if let sourceNode = surfaceTree.root?.node(view: source) {
|
||||
// Source is in our tree - same window move
|
||||
let treeWithoutSource = surfaceTree.removing(sourceNode)
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Move Split")
|
||||
return
|
||||
}
|
||||
|
||||
// Source is not in our tree - search other windows
|
||||
var sourceController: BaseTerminalController?
|
||||
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else { continue }
|
||||
guard controller !== self else { continue }
|
||||
if let node = controller.surfaceTree.root?.node(view: source) {
|
||||
sourceController = controller
|
||||
sourceNode = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let sourceController, let sourceNode else {
|
||||
Ghostty.logger.warning("source surface not found in any window during drop")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from source controller's tree and add it to our tree.
|
||||
// We do this first because if there is an error then we can
|
||||
// abort.
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
// Remove the node from the source.
|
||||
sourceController.removeSurfaceNode(sourceNode)
|
||||
|
||||
// Add in the surface to our tree
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface)
|
||||
}
|
||||
|
||||
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
|
||||
@@ -1076,6 +1212,15 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
// If when we become key our first responder is the window itself, then we
|
||||
// want to move focus to our focused terminal surface. This works around
|
||||
// various weirdness with moving surfaces around.
|
||||
if let window, window.firstResponder == window, let focusedSurface {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// Becoming/losing key means we have to notify our surface(s) that we have focus
|
||||
// so things like cursors blink, pty events are sent, etc.
|
||||
self.syncFocusToSurfaceTree()
|
||||
@@ -1227,7 +1372,15 @@ class BaseTerminalController: NSWindowController,
|
||||
@IBAction func find(_ sender: Any) {
|
||||
focusedSurface?.find(sender)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func selectionForFind(_ sender: Any) {
|
||||
focusedSurface?.selectionForFind(sender)
|
||||
}
|
||||
|
||||
@IBAction func scrollToSelection(_ sender: Any) {
|
||||
focusedSurface?.scrollToSelection(sender)
|
||||
}
|
||||
|
||||
@IBAction func findNext(_ sender: Any) {
|
||||
focusedSurface?.findNext(sender)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ import GhosttyKit
|
||||
class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
|
||||
override var windowNibName: NSNib.Name? {
|
||||
let defaultValue = "Terminal"
|
||||
|
||||
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
|
||||
let config = appDelegate.ghostty.config
|
||||
|
||||
|
||||
// If we have no window decorations, there's no reason to do anything but
|
||||
// the default titlebar (because there will be no titlebar).
|
||||
if !config.windowDecorations {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
|
||||
let nib = switch config.macosTitlebarStyle {
|
||||
case "native": "Terminal"
|
||||
case "hidden": "TerminalHiddenTitlebar"
|
||||
@@ -34,33 +34,33 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
#endif
|
||||
default: defaultValue
|
||||
}
|
||||
|
||||
|
||||
return nib
|
||||
}
|
||||
|
||||
|
||||
/// This is set to true when we care about frame changes. This is a small optimization since
|
||||
/// this controller registers a listener for ALL frame change notifications and this lets us bail
|
||||
/// early if we don't care.
|
||||
private var tabListenForFrame: Bool = false
|
||||
|
||||
|
||||
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
|
||||
/// changes in the list.
|
||||
private var tabWindowsHash: Int = 0
|
||||
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
private var restorable: Bool = true
|
||||
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig
|
||||
|
||||
|
||||
|
||||
|
||||
/// The notification cancellable for focused surface property changes.
|
||||
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
|
||||
/// This will be set to the initial frame of the window from the xib on load.
|
||||
private var initialFrame: NSRect? = nil
|
||||
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
|
||||
@@ -72,12 +72,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// as the script. We may want to revisit this behavior when we have scrollback
|
||||
// restoration.
|
||||
self.restorable = (base?.command ?? "") == ""
|
||||
|
||||
|
||||
// Setup our initial derived config based on the current app config
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
|
||||
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
||||
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
@@ -134,36 +134,56 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported for this view")
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Remove all of our notificationcenter subscriptions
|
||||
let center = NotificationCenter.default
|
||||
center.removeObserver(self)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Base Controller Overrides
|
||||
|
||||
|
||||
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
super.surfaceTreeDidChange(from: from, to: to)
|
||||
|
||||
|
||||
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
||||
// we want to invalidate our state.
|
||||
invalidateRestorableState()
|
||||
|
||||
|
||||
// Update our zoom state
|
||||
if let window = window as? TerminalWindow {
|
||||
window.surfaceIsZoomed = to.zoomed != nil
|
||||
}
|
||||
|
||||
|
||||
// If our surface tree is now nil then we close our window.
|
||||
if (to.isEmpty) {
|
||||
self.window?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
undoAction: String? = nil
|
||||
) {
|
||||
// We have a special case if our tree is empty to close our tab immediately.
|
||||
// This makes it so that undo is handled properly.
|
||||
if newTree.isEmpty {
|
||||
closeTabImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
super.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: oldView,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
|
||||
// MARK: Terminal Creation
|
||||
|
||||
@@ -275,6 +295,72 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
return c
|
||||
}
|
||||
|
||||
/// Create a new window with an existing split tree.
|
||||
/// The window will be sized to match the tree's current view bounds if available.
|
||||
/// - Parameters:
|
||||
/// - ghostty: The Ghostty app instance.
|
||||
/// - tree: The split tree to use for the new window.
|
||||
/// - position: Optional screen position (top-left corner) for the new window.
|
||||
/// If nil, the window will cascade from the last cascade point.
|
||||
static func newWindow(
|
||||
_ ghostty: Ghostty.App,
|
||||
tree: SplitTree<Ghostty.SurfaceView>,
|
||||
position: NSPoint? = nil,
|
||||
confirmUndo: Bool = true,
|
||||
) -> TerminalController {
|
||||
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
|
||||
|
||||
// Calculate the target frame based on the tree's view bounds
|
||||
let treeSize: CGSize? = tree.root?.viewBounds()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let window = c.window {
|
||||
// If we have a tree size, resize the window's content to match
|
||||
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
|
||||
window.setContentSize(treeSize)
|
||||
window.constrainToScreen()
|
||||
}
|
||||
|
||||
if !window.styleMask.contains(.fullScreen) {
|
||||
if let position {
|
||||
window.setFrameTopLeftPoint(position)
|
||||
window.constrainToScreen()
|
||||
} else {
|
||||
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.showWindow(self)
|
||||
}
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager = c.undoManager {
|
||||
undoManager.setActionName("New Window")
|
||||
undoManager.registerUndo(
|
||||
withTarget: c,
|
||||
expiresAfter: c.undoExpiration
|
||||
) { target in
|
||||
undoManager.disableUndoRegistration {
|
||||
if confirmUndo {
|
||||
target.closeWindow(nil)
|
||||
} else {
|
||||
target.closeWindowImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: ghostty,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { ghostty in
|
||||
_ = TerminalController.newWindow(ghostty, tree: tree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
static func newTab(
|
||||
_ ghostty: Ghostty.App,
|
||||
from parent: NSWindow? = nil,
|
||||
@@ -397,7 +483,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
//MARK: - Methods
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
@@ -548,7 +634,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
closeWindow(nil)
|
||||
}
|
||||
|
||||
private func closeTabImmediately(registerRedo: Bool = true) {
|
||||
func closeTabImmediately(registerRedo: Bool = true) {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup,
|
||||
tabGroup.windows.count > 1 else {
|
||||
@@ -671,7 +757,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
/// Closes the current window (including any other tabs) immediately and without
|
||||
/// confirmation. This will setup proper undo state so the action can be undone.
|
||||
private func closeWindowImmediately() {
|
||||
func closeWindowImmediately() {
|
||||
guard let window = window else { return }
|
||||
|
||||
registerUndoForCloseWindow()
|
||||
@@ -879,13 +965,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// Make it the key window
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
|
||||
// Restore focus to the previously focused surface
|
||||
if let focusedUUID = undoState.focusedSurface,
|
||||
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusTarget, from: nil)
|
||||
}
|
||||
} else if let focusedSurface = surfaceTree.first {
|
||||
// No prior focused surface or we can't find it, let's focus
|
||||
// the first.
|
||||
self.focusedSurface = focusedSurface
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,11 +1029,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
|
||||
// Initialize our content view to the SwiftUI root
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self,
|
||||
))
|
||||
)
|
||||
|
||||
// If we have a default size, we want to apply it.
|
||||
if let defaultSize {
|
||||
@@ -952,9 +1045,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
case .contentIntrinsicSize:
|
||||
// Content intrinsic size requires a short delay so that AppKit
|
||||
// can layout our SwiftUI views.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in
|
||||
guard let window else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
defaultSize.apply(to: window)
|
||||
if let screen = window.screen ?? NSScreen.main {
|
||||
let frame = self.adjustForWindowPosition(frame: window.frame, on: screen)
|
||||
window.setFrameOrigin(frame.origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
import os
|
||||
|
||||
/// This delegate is notified of actions and property changes regarding the terminal view. This
|
||||
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
|
||||
@@ -16,9 +17,9 @@ protocol TerminalViewDelegate: AnyObject {
|
||||
|
||||
/// Perform an action. At the time of writing this is only triggered by the command palette.
|
||||
func performAction(_ action: String, on: Ghostty.SurfaceView)
|
||||
|
||||
/// A split is resizing to a given value.
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
|
||||
|
||||
/// A split tree operation
|
||||
func performSplitAction(_ action: TerminalSplitOperation)
|
||||
}
|
||||
|
||||
/// The view model is a required implementation for TerminalView callers. This contains
|
||||
@@ -81,7 +82,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
|
||||
TerminalSplitTreeView(
|
||||
tree: viewModel.surfaceTree,
|
||||
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
|
||||
action: { delegate?.performSplitAction($0) })
|
||||
.environmentObject(ghostty)
|
||||
.focused($focused)
|
||||
.onAppear { self.focused = true }
|
||||
|
||||
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Use this container to achieve a glass effect at the window level.
|
||||
/// Modifying `NSThemeFrame` can sometimes be unpredictable.
|
||||
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
|
||||
private let terminalView: NSView
|
||||
|
||||
/// Glass effect view for liquid glass background when transparency is enabled
|
||||
private var glassEffectView: NSView?
|
||||
private var glassTopConstraint: NSLayoutConstraint?
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
|
||||
self.derivedConfig = DerivedConfig(config: ghostty.config)
|
||||
self.terminalView = NSHostingView(rootView: TerminalView(
|
||||
ghostty: ghostty,
|
||||
viewModel: viewModel,
|
||||
delegate: delegate
|
||||
))
|
||||
super.init(frame: .zero)
|
||||
setup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// To make ``TerminalController/DefaultSize/contentIntrinsicSize``
|
||||
/// work in ``TerminalController/windowDidLoad()``,
|
||||
/// we override this to provide the correct size.
|
||||
override var intrinsicContentSize: NSSize {
|
||||
terminalView.intrinsicContentSize
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
addSubview(terminalView)
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.topAnchor.constraint(equalTo: topAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
terminalView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
updateGlassEffectIfNeeded()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
guard let config = notification.userInfo?[
|
||||
Notification.Name.GhosttyConfigChangeKey
|
||||
] as? Ghostty.Config else { return }
|
||||
let newValue = DerivedConfig(config: config)
|
||||
guard newValue != derivedConfig else { return }
|
||||
derivedConfig = newValue
|
||||
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Glass
|
||||
|
||||
private extension TerminalViewContainer {
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
|
||||
if let existed = glassEffectView as? NSGlassEffectView {
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
return existed
|
||||
}
|
||||
guard let themeFrameView = window?.contentView?.superview else {
|
||||
return nil
|
||||
}
|
||||
let effectView = NSGlassEffectView()
|
||||
addSubview(effectView, positioned: .below, relativeTo: terminalView)
|
||||
effectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
glassTopConstraint = effectView.topAnchor.constraint(
|
||||
equalTo: topAnchor,
|
||||
constant: -themeFrameView.safeAreaInsets.top
|
||||
)
|
||||
if let glassTopConstraint {
|
||||
NSLayoutConstraint.activate([
|
||||
glassTopConstraint,
|
||||
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
glassEffectView = effectView
|
||||
return effectView
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
|
||||
func updateGlassEffectIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
glassEffectView?.removeFromSuperview()
|
||||
glassEffectView = nil
|
||||
glassTopConstraint = nil
|
||||
return
|
||||
}
|
||||
guard let effectView = addGlassEffectViewIfNeeded() else {
|
||||
return
|
||||
}
|
||||
switch derivedConfig.backgroundBlur {
|
||||
case .macosGlassRegular:
|
||||
effectView.style = NSGlassEffectView.Style.regular
|
||||
case .macosGlassClear:
|
||||
effectView.style = NSGlassEffectView.Style.clear
|
||||
default:
|
||||
break
|
||||
}
|
||||
let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor)
|
||||
effectView.tintColor = backgroundColor
|
||||
.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||
if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat {
|
||||
effectView.cornerRadius = cornerRadius
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
func updateGlassEffectTopInsetIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
return
|
||||
}
|
||||
guard glassEffectView != nil else { return }
|
||||
guard let themeFrameView = window?.contentView?.superview else { return }
|
||||
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
struct DerivedConfig: Equatable {
|
||||
var backgroundOpacity: Double = 0
|
||||
var backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
var backgroundColor: Color = .clear
|
||||
|
||||
init(config: Ghostty.Config) {
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundColor = config.backgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
// Its possible we miss the accessory titlebar call so we check again
|
||||
// whenever the window becomes main. Both of these are idempotent.
|
||||
if hasTabBar {
|
||||
if tabBarView != nil {
|
||||
tabBarDidAppear()
|
||||
} else {
|
||||
tabBarDidDisappear()
|
||||
@@ -243,31 +243,6 @@ class TerminalWindow: NSWindow {
|
||||
/// added.
|
||||
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||
|
||||
func findTitlebarView() -> NSView? {
|
||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||
//
|
||||
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||
guard let themeFrameView = contentView?.rootView else { return nil }
|
||||
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
|
||||
themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||
} else {
|
||||
NSView?.none
|
||||
}
|
||||
return titlebarView
|
||||
}
|
||||
|
||||
func findTabBar() -> NSView? {
|
||||
findTitlebarView()?.firstDescendant(withClassName: "NSTabBar")
|
||||
}
|
||||
|
||||
/// Returns true if there is a tab bar visible on this window.
|
||||
var hasTabBar: Bool {
|
||||
findTabBar() != nil
|
||||
}
|
||||
|
||||
var hasMoreThanOneTabs: Bool {
|
||||
/// accessing ``tabGroup?.windows`` here
|
||||
/// will cause other edge cases, be careful
|
||||
@@ -474,7 +449,7 @@ class TerminalWindow: NSWindow {
|
||||
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
|
||||
if !styleMask.contains(.fullScreen) &&
|
||||
!forceOpaque &&
|
||||
surfaceConfig.backgroundOpacity < 1
|
||||
(surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle)
|
||||
{
|
||||
isOpaque = false
|
||||
|
||||
@@ -483,15 +458,8 @@ class TerminalWindow: NSWindow {
|
||||
// Terminal.app more easily.
|
||||
backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
// Add liquid glass behind terminal content
|
||||
if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle {
|
||||
setupGlassLayer()
|
||||
} else if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
// If we had a prior glass layer we should remove it
|
||||
if #available(macOS 26.0, *) {
|
||||
removeGlassLayer()
|
||||
}
|
||||
|
||||
// We don't need to set blur when using glass
|
||||
if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
ghostty_set_window_background_blur(
|
||||
appDelegate.ghostty.app,
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
@@ -499,11 +467,6 @@ class TerminalWindow: NSWindow {
|
||||
} else {
|
||||
isOpaque = true
|
||||
|
||||
// Remove liquid glass when not transparent
|
||||
if #available(macOS 26.0, *) {
|
||||
removeGlassLayer()
|
||||
}
|
||||
|
||||
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
|
||||
self.backgroundColor = backgroundColor.withAlphaComponent(1)
|
||||
}
|
||||
@@ -581,50 +544,6 @@ class TerminalWindow: NSWindow {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
// MARK: Glass
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func setupGlassLayer() {
|
||||
// Remove existing glass effect view
|
||||
removeGlassLayer()
|
||||
|
||||
// Get the window content view (parent of the NSHostingView)
|
||||
guard let contentView else { return }
|
||||
guard let windowContentView = contentView.superview else { return }
|
||||
|
||||
// Create NSGlassEffectView for native glass effect
|
||||
let effectView = NSGlassEffectView()
|
||||
|
||||
// Map Ghostty config to NSGlassEffectView style
|
||||
switch derivedConfig.backgroundBlur {
|
||||
case .macosGlassRegular:
|
||||
effectView.style = NSGlassEffectView.Style.regular
|
||||
case .macosGlassClear:
|
||||
effectView.style = NSGlassEffectView.Style.clear
|
||||
default:
|
||||
// Should not reach here since we check for glass style before calling
|
||||
// setupGlassLayer()
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
effectView.cornerRadius = derivedConfig.windowCornerRadius
|
||||
effectView.tintColor = preferredBackgroundColor
|
||||
effectView.frame = windowContentView.bounds
|
||||
effectView.autoresizingMask = [.width, .height]
|
||||
|
||||
// Position BELOW the terminal content to act as background
|
||||
windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView)
|
||||
glassEffectView = effectView
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func removeGlassLayer() {
|
||||
glassEffectView?.removeFromSuperview()
|
||||
glassEffectView = nil
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
|
||||
// MARK: Config
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
return
|
||||
}
|
||||
|
||||
guard let tabBarView = findTabBar() else {
|
||||
guard let tabBarView else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
@@ -176,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
guard tabBarObserver == nil else { return }
|
||||
|
||||
guard
|
||||
let titlebarView = findTitlebarView(),
|
||||
let tabBar = findTabBar()
|
||||
let titlebarView,
|
||||
let tabBarView = self.tabBarView
|
||||
else { return }
|
||||
|
||||
// View model updates must happen on their own ticks.
|
||||
@@ -186,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
}
|
||||
|
||||
// Find our clip view
|
||||
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
||||
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||
|
||||
// Make sure tabBar's height won't be stretched
|
||||
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
|
||||
tabBar.frame.size.height = newTabButton.frame.width
|
||||
tabBarView.frame.size.height = newTabButton.frame.width
|
||||
|
||||
// The container is the view that we'll constrain our tab bar within.
|
||||
let container = toolbarView
|
||||
@@ -228,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
// other events occur, the tab bar can resize and clear our constraints. When this
|
||||
// happens, we need to remove our custom constraints and re-apply them once the
|
||||
// tab bar has proper dimensions again to avoid constraint conflicts.
|
||||
tabBar.postsFrameChangedNotifications = true
|
||||
tabBarView.postsFrameChangedNotifications = true
|
||||
tabBarObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: tabBar,
|
||||
object: tabBarView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -322,7 +322,8 @@ extension TitlebarTabsTahoeTerminalWindow {
|
||||
} else {
|
||||
// 1x1.gif strikes again! For real: if we render a zero-sized
|
||||
// view here then the toolbar just disappears our view. I don't
|
||||
// know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
|
||||
// know. On macOS 26.1+ the view no longer disappears, but the
|
||||
// toolbar still logs an ambiguous content size warning.
|
||||
Color.clear.frame(width: 1, height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
/// Titlebar tabs can't support the update accessory because of the way we layout
|
||||
/// the native tabs back into the menu bar.
|
||||
override var supportsUpdateAccessory: Bool { false }
|
||||
|
||||
|
||||
/// This is used to determine if certain elements should be drawn light or dark and should
|
||||
/// be updated whenever the window background color or surrounding elements changes.
|
||||
fileprivate var isLightTheme: Bool = false
|
||||
@@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
// Hide the window drag handle.
|
||||
windowDragHandle?.isHidden = true
|
||||
|
||||
// Reenable the main toolbar title
|
||||
// Re-enable the main toolbar title
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleIsHidden = false
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
/// This is necessary because various macOS operations (tab switching, tab bar
|
||||
/// visibility changes) can reset the titlebar appearance.
|
||||
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
|
||||
|
||||
|
||||
/// KVO observation for tab group window changes.
|
||||
private var tabGroupWindowsObservation: NSKeyValueObservation?
|
||||
private var tabBarVisibleObservation: NSKeyValueObservation?
|
||||
|
||||
|
||||
deinit {
|
||||
tabGroupWindowsObservation?.invalidate()
|
||||
tabBarVisibleObservation?.invalidate()
|
||||
}
|
||||
|
||||
|
||||
// MARK: NSWindow
|
||||
|
||||
override func awakeFromNib() {
|
||||
@@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
|
||||
override func becomeMain() {
|
||||
super.becomeMain()
|
||||
|
||||
|
||||
guard let lastSurfaceConfig else { return }
|
||||
syncAppearance(lastSurfaceConfig)
|
||||
|
||||
@@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func update() {
|
||||
super.update()
|
||||
|
||||
@@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Save our config in case we need to reapply
|
||||
lastSurfaceConfig = surfaceConfig
|
||||
|
||||
// Everytime we change appearance, set KVO up again in case any of our
|
||||
// Every time we change appearance, set KVO up again in case any of our
|
||||
// references changed (e.g. tabGroup is new).
|
||||
setupKVO()
|
||||
|
||||
@@ -99,7 +99,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
? NSColor.clear.cgColor
|
||||
: preferredBackgroundColor?.cgColor
|
||||
}
|
||||
|
||||
|
||||
// In all cases, we have to hide the background view since this has multiple subviews
|
||||
// that force a background color.
|
||||
titlebarBackgroundView?.isHidden = true
|
||||
@@ -108,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
@available(macOS 13.0, *)
|
||||
private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
||||
guard let titlebarContainer else { return }
|
||||
|
||||
|
||||
// Setup the titlebar background color to match ours
|
||||
titlebarContainer.wantsLayer = true
|
||||
titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
|
||||
|
||||
|
||||
// See the docs for the function that sets this to true on why
|
||||
effectViewIsHidden = false
|
||||
|
||||
|
||||
// Necessary to not draw the border around the title
|
||||
titlebarAppearsTransparent = true
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Remove existing observation if any
|
||||
tabGroupWindowsObservation?.invalidate()
|
||||
tabGroupWindowsObservation = nil
|
||||
|
||||
|
||||
// Check if tabGroup is available
|
||||
guard let tabGroup else { return }
|
||||
|
||||
@@ -170,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
// Remove existing observation if any
|
||||
tabBarVisibleObservation?.invalidate()
|
||||
tabBarVisibleObservation = nil
|
||||
|
||||
|
||||
// Set up KVO observation for isTabBarVisible
|
||||
tabBarVisibleObservation = tabGroup?.observe(
|
||||
\.isTabBarVisible,
|
||||
@@ -181,18 +181,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
|
||||
self.syncAppearance(lastSurfaceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: macOS 13 to 15
|
||||
|
||||
|
||||
// We only need to set this once, but need to do it after the window has been created in order
|
||||
// to determine if the theme is using a very dark background, in which case we don't want to
|
||||
// remove the effect view if the default tab bar is being used since the effect created in
|
||||
// `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
|
||||
private var effectViewIsHidden = false
|
||||
|
||||
|
||||
private func hideEffectView() {
|
||||
guard !effectViewIsHidden else { return }
|
||||
|
||||
|
||||
// By hiding the visual effect view, we allow the window's (or titlebar's in this case)
|
||||
// background color to show through. If we were to set `titlebarAppearsTransparent` to true
|
||||
// the selected tab would look fine, but the unselected ones and new tab button backgrounds
|
||||
|
||||
@@ -141,6 +141,27 @@ extension Ghostty.Action {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum KeyTable {
|
||||
case activate(name: String)
|
||||
case deactivate
|
||||
case deactivateAll
|
||||
|
||||
init?(c: ghostty_action_key_table_s) {
|
||||
switch c.tag {
|
||||
case GHOSTTY_KEY_TABLE_ACTIVATE:
|
||||
let data = Data(bytes: c.value.activate.name, count: c.value.activate.len)
|
||||
let name = String(data: data, encoding: .utf8) ?? ""
|
||||
self = .activate(name: name)
|
||||
case GHOSTTY_KEY_TABLE_DEACTIVATE:
|
||||
self = .deactivate
|
||||
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
|
||||
self = .deactivateAll
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Putting the initializer in an extension preserves the automatic one.
|
||||
|
||||
@@ -29,6 +29,8 @@ extension Ghostty {
|
||||
/// configuration (i.e. font size) from the previously focused window. This would override this.
|
||||
@Published private(set) var config: Config
|
||||
|
||||
/// Preferred config file than the default ones
|
||||
private var configPath: String?
|
||||
/// The ghostty app instance. We only have one of these for the entire app, although I guess
|
||||
/// in theory you can have multiple... I don't know why you would...
|
||||
@Published var app: ghostty_app_t? = nil {
|
||||
@@ -44,9 +46,10 @@ extension Ghostty {
|
||||
return ghostty_app_needs_confirm_quit(app)
|
||||
}
|
||||
|
||||
init() {
|
||||
init(configPath: String? = nil) {
|
||||
self.configPath = configPath
|
||||
// Initialize the global configuration.
|
||||
self.config = Config()
|
||||
self.config = Config(at: configPath)
|
||||
if self.config.config == nil {
|
||||
readiness = .error
|
||||
return
|
||||
@@ -143,7 +146,7 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
// Hard or full updates have to reload the full configuration
|
||||
let newConfig = Config()
|
||||
let newConfig = Config(at: configPath)
|
||||
guard newConfig.loaded else {
|
||||
Ghostty.logger.warning("failed to reload configuration")
|
||||
return
|
||||
@@ -163,7 +166,7 @@ extension Ghostty {
|
||||
// Hard or full updates have to reload the full configuration.
|
||||
// NOTE: We never set this on self.config because this is a surface-only
|
||||
// config. We free it after the call.
|
||||
let newConfig = Config()
|
||||
let newConfig = Config(at: configPath)
|
||||
guard newConfig.loaded else {
|
||||
Ghostty.logger.warning("failed to reload configuration")
|
||||
return
|
||||
@@ -578,7 +581,10 @@ extension Ghostty {
|
||||
|
||||
case GHOSTTY_ACTION_KEY_SEQUENCE:
|
||||
keySequence(app, target: target, v: action.action.key_sequence)
|
||||
|
||||
|
||||
case GHOSTTY_ACTION_KEY_TABLE:
|
||||
keyTable(app, target: target, v: action.action.key_table)
|
||||
|
||||
case GHOSTTY_ACTION_PROGRESS_REPORT:
|
||||
progressReport(app, target: target, v: action.action.progress_report)
|
||||
|
||||
@@ -770,7 +776,7 @@ extension Ghostty {
|
||||
name: Notification.ghosttyNewWindow,
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -807,7 +813,7 @@ extension Ghostty {
|
||||
name: Notification.ghosttyNewTab,
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -836,7 +842,7 @@ extension Ghostty {
|
||||
object: surfaceView,
|
||||
userInfo: [
|
||||
"direction": direction,
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1771,7 +1777,32 @@ extension Ghostty {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func keyTable(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
v: ghostty_action_key_table_s) {
|
||||
switch (target.tag) {
|
||||
case GHOSTTY_TARGET_APP:
|
||||
Ghostty.logger.warning("key table does nothing with an app target")
|
||||
return
|
||||
|
||||
case GHOSTTY_TARGET_SURFACE:
|
||||
guard let surface = target.target.surface else { return }
|
||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||
guard let action = Ghostty.Action.KeyTable(c: v) else { return }
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didChangeKeyTable,
|
||||
object: surfaceView,
|
||||
userInfo: [Notification.KeyTableKey: action]
|
||||
)
|
||||
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private static func progressReport(
|
||||
_ app: ghostty_app_t,
|
||||
target: ghostty_target_s,
|
||||
@@ -1841,11 +1872,15 @@ extension Ghostty {
|
||||
|
||||
let startSearch = Ghostty.Action.StartSearch(c: v)
|
||||
DispatchQueue.main.async {
|
||||
if surfaceView.searchState != nil {
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
|
||||
if let searchState = surfaceView.searchState {
|
||||
if let needle = startSearch.needle, !needle.isEmpty {
|
||||
searchState.needle = needle
|
||||
}
|
||||
} else {
|
||||
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -3,28 +3,18 @@ import GhosttyKit
|
||||
extension Ghostty {
|
||||
/// `ghostty_command_s`
|
||||
struct Command: Sendable {
|
||||
private let cValue: ghostty_command_s
|
||||
|
||||
/// The title of the command.
|
||||
var title: String {
|
||||
String(cString: cValue.title)
|
||||
}
|
||||
let title: String
|
||||
|
||||
/// Human-friendly description of what this command will do.
|
||||
var description: String {
|
||||
String(cString: cValue.description)
|
||||
}
|
||||
let description: String
|
||||
|
||||
/// The full action that must be performed to invoke this command.
|
||||
var action: String {
|
||||
String(cString: cValue.action)
|
||||
}
|
||||
let action: String
|
||||
|
||||
/// Only the key portion of the action so you can compare action types, e.g. `goto_split`
|
||||
/// instead of `goto_split:left`.
|
||||
var actionKey: String {
|
||||
String(cString: cValue.action_key)
|
||||
}
|
||||
let actionKey: String
|
||||
|
||||
/// True if this can be performed on this target.
|
||||
var isSupported: Bool {
|
||||
@@ -40,7 +30,10 @@ extension Ghostty {
|
||||
]
|
||||
|
||||
init(cValue: ghostty_command_s) {
|
||||
self.cValue = cValue
|
||||
self.title = String(cString: cValue.title)
|
||||
self.description = String(cString: cValue.description)
|
||||
self.action = String(cString: cValue.action)
|
||||
self.actionKey = String(cString: cValue.action_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ extension Ghostty {
|
||||
return diags
|
||||
}
|
||||
|
||||
init() {
|
||||
if let cfg = Self.loadConfig() {
|
||||
self.config = cfg
|
||||
}
|
||||
init(config: ghostty_config_t?) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
init(clone config: ghostty_config_t) {
|
||||
self.config = ghostty_config_clone(config)
|
||||
convenience init(at path: String? = nil, finalize: Bool = true) {
|
||||
self.init(config: Self.loadConfig(at: path, finalize: finalize))
|
||||
}
|
||||
|
||||
convenience init(clone config: ghostty_config_t) {
|
||||
self.init(config: ghostty_config_clone(config))
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -48,7 +50,10 @@ extension Ghostty {
|
||||
}
|
||||
|
||||
/// Initializes a new configuration and loads all the values.
|
||||
static private func loadConfig() -> ghostty_config_t? {
|
||||
/// - Parameters:
|
||||
/// - path: An optional preferred config file path. Pass `nil` to load the default configuration files.
|
||||
/// - finalize: Whether to finalize the configuration to populate default values.
|
||||
static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? {
|
||||
// Initialize the global configuration.
|
||||
guard let cfg = ghostty_config_new() else {
|
||||
logger.critical("ghostty_config_new failed")
|
||||
@@ -59,7 +64,11 @@ extension Ghostty {
|
||||
// We only do this on macOS because other Apple platforms do not have the
|
||||
// same filesystem concept.
|
||||
#if os(macOS)
|
||||
ghostty_config_load_default_files(cfg);
|
||||
if let path {
|
||||
ghostty_config_load_file(cfg, path)
|
||||
} else {
|
||||
ghostty_config_load_default_files(cfg)
|
||||
}
|
||||
|
||||
// We only load CLI args when not running in Xcode because in Xcode we
|
||||
// pass some special parameters to control the debugger.
|
||||
@@ -74,9 +83,10 @@ extension Ghostty {
|
||||
// have to do this synchronously. When we support config updating we can do
|
||||
// this async and update later.
|
||||
|
||||
// Finalize will make our defaults available.
|
||||
ghostty_config_finalize(cfg)
|
||||
|
||||
if finalize {
|
||||
// Finalize will make our defaults available.
|
||||
ghostty_config_finalize(cfg)
|
||||
}
|
||||
// Log any configuration errors. These will be automatically shown in a
|
||||
// pop-up window too.
|
||||
let diagsCount = ghostty_config_diagnostics_count(cfg)
|
||||
@@ -622,6 +632,16 @@ extension Ghostty {
|
||||
let str = String(cString: ptr)
|
||||
return Scrollbar(rawValue: str) ?? defaultValue
|
||||
}
|
||||
|
||||
var commandPaletteEntries: [Ghostty.Command] {
|
||||
guard let config = self.config else { return [] }
|
||||
var v: ghostty_config_command_list_s = .init()
|
||||
let key = "command-palette-entry"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] }
|
||||
guard v.len > 0 else { return [] }
|
||||
let buffer = UnsafeBufferPointer(start: v.commands, count: v.len)
|
||||
return buffer.map { Ghostty.Command(cValue: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +668,17 @@ extension Ghostty.Config {
|
||||
case 0:
|
||||
self = .disabled
|
||||
case -1:
|
||||
self = .macosGlassRegular
|
||||
if #available(macOS 26.0, *) {
|
||||
self = .macosGlassRegular
|
||||
} else {
|
||||
self = .disabled
|
||||
}
|
||||
case -2:
|
||||
self = .macosGlassClear
|
||||
if #available(macOS 26.0, *) {
|
||||
self = .macosGlassClear
|
||||
} else {
|
||||
self = .disabled
|
||||
}
|
||||
default:
|
||||
self = .radius(Int(value))
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ extension Ghostty {
|
||||
guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }
|
||||
key = KeyEquivalent(Character(scalar))
|
||||
|
||||
case GHOSTTY_TRIGGER_CATCH_ALL:
|
||||
// catch_all matches any key, so it can't be represented as a KeyboardShortcut
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -64,7 +68,7 @@ extension Ghostty {
|
||||
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
|
||||
|
||||
// Handle sided input. We can't tell that both are pressed in the
|
||||
// Ghostty structure but thats okay -- we don't use that information.
|
||||
// Ghostty structure but that's okay -- we don't use that information.
|
||||
let rawFlags = flags.rawValue
|
||||
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
|
||||
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
|
||||
@@ -96,6 +100,32 @@ extension Ghostty {
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.BindingFlags
|
||||
|
||||
extension Ghostty.Input {
|
||||
/// `ghostty_binding_flags_e`
|
||||
struct BindingFlags: OptionSet, Sendable {
|
||||
let rawValue: UInt32
|
||||
|
||||
static let consumed = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue)
|
||||
static let all = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_ALL.rawValue)
|
||||
static let global = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_GLOBAL.rawValue)
|
||||
static let performable = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue)
|
||||
|
||||
init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
init(cFlags: ghostty_binding_flags_e) {
|
||||
self.rawValue = cFlags.rawValue
|
||||
}
|
||||
|
||||
var cFlags: ghostty_binding_flags_e {
|
||||
ghostty_binding_flags_e(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Ghostty.Input.KeyEvent
|
||||
|
||||
extension Ghostty.Input {
|
||||
@@ -135,7 +165,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_ACTION_REPEAT: self.action = .repeat
|
||||
default: self.action = .press
|
||||
}
|
||||
|
||||
|
||||
// Convert key from keycode
|
||||
guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
|
||||
self.key = key
|
||||
@@ -146,18 +176,18 @@ extension Ghostty.Input {
|
||||
} else {
|
||||
self.text = nil
|
||||
}
|
||||
|
||||
|
||||
// Set composing state
|
||||
self.composing = cValue.composing
|
||||
|
||||
|
||||
// Convert modifiers
|
||||
self.mods = Mods(cMods: cValue.mods)
|
||||
self.consumedMods = Mods(cMods: cValue.consumed_mods)
|
||||
|
||||
|
||||
// Set unshifted codepoint
|
||||
self.unshiftedCodepoint = cValue.unshifted_codepoint
|
||||
}
|
||||
|
||||
|
||||
/// Executes a closure with a temporary C representation of this KeyEvent.
|
||||
///
|
||||
/// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
|
||||
@@ -176,7 +206,7 @@ extension Ghostty.Input {
|
||||
keyEvent.mods = mods.cMods
|
||||
keyEvent.consumed_mods = consumedMods.cMods
|
||||
keyEvent.unshifted_codepoint = unshiftedCodepoint
|
||||
|
||||
|
||||
// Handle text with proper memory management
|
||||
if let text = text {
|
||||
return text.withCString { textPtr in
|
||||
@@ -199,7 +229,7 @@ extension Ghostty.Input {
|
||||
case release
|
||||
case press
|
||||
case `repeat`
|
||||
|
||||
|
||||
var cAction: ghostty_input_action_e {
|
||||
switch self {
|
||||
case .release: GHOSTTY_ACTION_RELEASE
|
||||
@@ -228,7 +258,7 @@ extension Ghostty.Input {
|
||||
let action: MouseState
|
||||
let button: MouseButton
|
||||
let mods: Mods
|
||||
|
||||
|
||||
init(
|
||||
action: MouseState,
|
||||
button: MouseButton,
|
||||
@@ -238,7 +268,7 @@ extension Ghostty.Input {
|
||||
self.button = button
|
||||
self.mods = mods
|
||||
}
|
||||
|
||||
|
||||
/// Creates a MouseEvent from C enum values.
|
||||
///
|
||||
/// This initializer converts C-style mouse input enums to Swift types.
|
||||
@@ -255,7 +285,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_MOUSE_PRESS: self.action = .press
|
||||
default: return nil
|
||||
}
|
||||
|
||||
|
||||
// Convert button
|
||||
switch button {
|
||||
case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown
|
||||
@@ -264,7 +294,7 @@ extension Ghostty.Input {
|
||||
case GHOSTTY_MOUSE_MIDDLE: self.button = .middle
|
||||
default: return nil
|
||||
}
|
||||
|
||||
|
||||
// Convert modifiers
|
||||
self.mods = Mods(cMods: mods)
|
||||
}
|
||||
@@ -275,7 +305,7 @@ extension Ghostty.Input {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let mods: Mods
|
||||
|
||||
|
||||
init(
|
||||
x: Double,
|
||||
y: Double,
|
||||
@@ -312,7 +342,7 @@ extension Ghostty.Input {
|
||||
enum MouseState: String, CaseIterable {
|
||||
case release
|
||||
case press
|
||||
|
||||
|
||||
var cMouseState: ghostty_input_mouse_state_e {
|
||||
switch self {
|
||||
case .release: GHOSTTY_MOUSE_RELEASE
|
||||
@@ -340,13 +370,48 @@ extension Ghostty.Input {
|
||||
case left
|
||||
case right
|
||||
case middle
|
||||
|
||||
case four
|
||||
case five
|
||||
case six
|
||||
case seven
|
||||
case eight
|
||||
case nine
|
||||
case ten
|
||||
case eleven
|
||||
|
||||
var cMouseButton: ghostty_input_mouse_button_e {
|
||||
switch self {
|
||||
case .unknown: GHOSTTY_MOUSE_UNKNOWN
|
||||
case .left: GHOSTTY_MOUSE_LEFT
|
||||
case .right: GHOSTTY_MOUSE_RIGHT
|
||||
case .middle: GHOSTTY_MOUSE_MIDDLE
|
||||
case .four: GHOSTTY_MOUSE_FOUR
|
||||
case .five: GHOSTTY_MOUSE_FIVE
|
||||
case .six: GHOSTTY_MOUSE_SIX
|
||||
case .seven: GHOSTTY_MOUSE_SEVEN
|
||||
case .eight: GHOSTTY_MOUSE_EIGHT
|
||||
case .nine: GHOSTTY_MOUSE_NINE
|
||||
case .ten: GHOSTTY_MOUSE_TEN
|
||||
case .eleven: GHOSTTY_MOUSE_ELEVEN
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize from NSEvent.buttonNumber
|
||||
/// NSEvent buttonNumber: 0=left, 1=right, 2=middle, 3=back (button 8), 4=forward (button 9), etc.
|
||||
init(fromNSEventButtonNumber buttonNumber: Int) {
|
||||
switch buttonNumber {
|
||||
case 0: self = .left
|
||||
case 1: self = .right
|
||||
case 2: self = .middle
|
||||
case 3: self = .eight // Back button
|
||||
case 4: self = .nine // Forward button
|
||||
case 5: self = .six
|
||||
case 6: self = .seven
|
||||
case 7: self = .four
|
||||
case 8: self = .five
|
||||
case 9: self = .ten
|
||||
case 10: self = .eleven
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,18 +443,18 @@ extension Ghostty.Input {
|
||||
/// for scroll events, matching the Zig `ScrollMods` packed struct.
|
||||
struct ScrollMods {
|
||||
let rawValue: Int32
|
||||
|
||||
|
||||
/// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)
|
||||
var precision: Bool {
|
||||
rawValue & 0b0000_0001 != 0
|
||||
}
|
||||
|
||||
|
||||
/// The momentum phase of the scroll event for inertial scrolling
|
||||
var momentum: Momentum {
|
||||
let momentumBits = (rawValue >> 1) & 0b0000_0111
|
||||
return Momentum(rawValue: UInt8(momentumBits)) ?? .none
|
||||
}
|
||||
|
||||
|
||||
init(precision: Bool = false, momentum: Momentum = .none) {
|
||||
var value: Int32 = 0
|
||||
if precision {
|
||||
@@ -398,11 +463,11 @@ extension Ghostty.Input {
|
||||
value |= Int32(momentum.rawValue) << 1
|
||||
self.rawValue = value
|
||||
}
|
||||
|
||||
|
||||
init(rawValue: Int32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
|
||||
var cScrollMods: ghostty_input_scroll_mods_t {
|
||||
rawValue
|
||||
}
|
||||
@@ -421,7 +486,7 @@ extension Ghostty.Input {
|
||||
case ended = 4
|
||||
case cancelled = 5
|
||||
case mayBegin = 6
|
||||
|
||||
|
||||
var cMomentum: ghostty_input_mouse_momentum_e {
|
||||
switch self {
|
||||
case .none: GHOSTTY_MOUSE_MOMENTUM_NONE
|
||||
@@ -438,7 +503,7 @@ extension Ghostty.Input {
|
||||
|
||||
extension Ghostty.Input.Momentum: AppEnum {
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum")
|
||||
|
||||
|
||||
static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [
|
||||
.none: "None",
|
||||
.began: "Began",
|
||||
@@ -475,7 +540,7 @@ extension Ghostty.Input {
|
||||
/// `ghostty_input_mods_e`
|
||||
struct Mods: OptionSet {
|
||||
let rawValue: UInt32
|
||||
|
||||
|
||||
static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
|
||||
static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
|
||||
static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
|
||||
@@ -486,23 +551,23 @@ extension Ghostty.Input {
|
||||
static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
|
||||
static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
|
||||
static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
|
||||
|
||||
|
||||
var cMods: ghostty_input_mods_e {
|
||||
ghostty_input_mods_e(rawValue)
|
||||
}
|
||||
|
||||
|
||||
init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
|
||||
init(cMods: ghostty_input_mods_e) {
|
||||
self.rawValue = cMods.rawValue
|
||||
}
|
||||
|
||||
|
||||
init(nsFlags: NSEvent.ModifierFlags) {
|
||||
self.init(cMods: Ghostty.ghosttyMods(nsFlags))
|
||||
}
|
||||
|
||||
|
||||
var nsFlags: NSEvent.ModifierFlags {
|
||||
Ghostty.eventModifierFlags(mods: cMods)
|
||||
}
|
||||
@@ -1116,43 +1181,43 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
return [
|
||||
// Letters (A-Z)
|
||||
.a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
|
||||
|
||||
|
||||
// Numbers (0-9)
|
||||
.digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
|
||||
|
||||
|
||||
// Common Control Keys
|
||||
.space, .enter, .tab, .backspace, .escape, .delete,
|
||||
|
||||
|
||||
// Arrow Keys
|
||||
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
|
||||
|
||||
|
||||
// Navigation Keys
|
||||
.home, .end, .pageUp, .pageDown, .insert,
|
||||
|
||||
|
||||
// Function Keys (F1-F20)
|
||||
.f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
|
||||
.f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
|
||||
|
||||
|
||||
// Modifier Keys
|
||||
.shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
|
||||
.metaLeft, .metaRight, .capsLock,
|
||||
|
||||
|
||||
// Punctuation & Symbols
|
||||
.minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
|
||||
.semicolon, .quote, .comma, .period, .slash,
|
||||
|
||||
|
||||
// Numpad
|
||||
.numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
|
||||
.numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
|
||||
.numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
|
||||
.numpadEnter, .numpadComma,
|
||||
|
||||
|
||||
// Media Keys
|
||||
.audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
|
||||
|
||||
|
||||
// International Keys
|
||||
.intlBackslash, .intlRo, .intlYen,
|
||||
|
||||
|
||||
// Other
|
||||
.contextMenu
|
||||
]
|
||||
@@ -1163,11 +1228,11 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
|
||||
.k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
|
||||
.u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
|
||||
|
||||
|
||||
// Numbers (0-9)
|
||||
.digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
|
||||
.digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
|
||||
|
||||
|
||||
// Common Control Keys
|
||||
.space: "Space",
|
||||
.enter: "Enter",
|
||||
@@ -1175,26 +1240,26 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.backspace: "Backspace",
|
||||
.escape: "Escape",
|
||||
.delete: "Delete",
|
||||
|
||||
|
||||
// Arrow Keys
|
||||
.arrowUp: "Up Arrow",
|
||||
.arrowDown: "Down Arrow",
|
||||
.arrowLeft: "Left Arrow",
|
||||
.arrowRight: "Right Arrow",
|
||||
|
||||
|
||||
// Navigation Keys
|
||||
.home: "Home",
|
||||
.end: "End",
|
||||
.pageUp: "Page Up",
|
||||
.pageDown: "Page Down",
|
||||
.insert: "Insert",
|
||||
|
||||
|
||||
// Function Keys (F1-F20)
|
||||
.f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
|
||||
.f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
|
||||
.f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
|
||||
.f18: "F18", .f19: "F19", .f20: "F20",
|
||||
|
||||
|
||||
// Modifier Keys
|
||||
.shiftLeft: "Left Shift",
|
||||
.shiftRight: "Right Shift",
|
||||
@@ -1205,7 +1270,7 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.metaLeft: "Left Command",
|
||||
.metaRight: "Right Command",
|
||||
.capsLock: "Caps Lock",
|
||||
|
||||
|
||||
// Punctuation & Symbols
|
||||
.minus: "Minus (-)",
|
||||
.equal: "Equal (=)",
|
||||
@@ -1218,7 +1283,7 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.comma: "Comma (,)",
|
||||
.period: "Period (.)",
|
||||
.slash: "Slash (/)",
|
||||
|
||||
|
||||
// Numpad
|
||||
.numLock: "Num Lock",
|
||||
.numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
|
||||
@@ -1232,17 +1297,17 @@ extension Ghostty.Input.Key: AppEnum {
|
||||
.numpadEqual: "Numpad Equal",
|
||||
.numpadEnter: "Numpad Enter",
|
||||
.numpadComma: "Numpad Comma",
|
||||
|
||||
|
||||
// Media Keys
|
||||
.audioVolumeUp: "Volume Up",
|
||||
.audioVolumeDown: "Volume Down",
|
||||
.audioVolumeMute: "Volume Mute",
|
||||
|
||||
|
||||
// International Keys
|
||||
.intlBackslash: "International Backslash",
|
||||
.intlRo: "International Ro",
|
||||
.intlYen: "International Yen",
|
||||
|
||||
|
||||
// Other
|
||||
.contextMenu: "Context Menu"
|
||||
]
|
||||
|
||||
@@ -62,6 +62,26 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a key event matches a keybinding.
|
||||
///
|
||||
/// This checks whether the given key event would trigger a keybinding in the terminal.
|
||||
/// If it matches, returns the binding flags indicating properties of the matched binding.
|
||||
///
|
||||
/// - Parameter event: The key event to check
|
||||
/// - Returns: The binding flags if a binding matches, or nil if no binding matches
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: ghostty_input_key_s) -> Input.BindingFlags? {
|
||||
var flags = ghostty_binding_flags_e(0)
|
||||
guard ghostty_surface_key_is_binding(surface, event, &flags) else { return nil }
|
||||
return Input.BindingFlags(cFlags: flags)
|
||||
}
|
||||
|
||||
/// See `keyIsBinding(_ event: ghostty_input_key_s)`.
|
||||
@MainActor
|
||||
func keyIsBinding(_ event: Input.KeyEvent) -> Input.BindingFlags? {
|
||||
event.withCValue { keyIsBinding($0) }
|
||||
}
|
||||
|
||||
/// Whether the terminal has captured mouse input.
|
||||
///
|
||||
/// When the mouse is captured, the terminal application is receiving mouse events
|
||||
@@ -134,16 +154,5 @@ extension Ghostty {
|
||||
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Command options for this surface.
|
||||
@MainActor
|
||||
func commands() throws -> [Command] {
|
||||
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
|
||||
var count: Int = 0
|
||||
ghostty_surface_commands(surface, &ptr, &count)
|
||||
guard let ptr else { throw Error.apiFailed }
|
||||
let buffer = UnsafeBufferPointer(start: ptr, count: count)
|
||||
return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension Ghostty {
|
||||
/// This is a delegate that should be applied to your global app delegate for GhosttyKit
|
||||
/// to perform app-global operations.
|
||||
protocol Delegate {
|
||||
/// Look up a surface within the application by ID.
|
||||
func ghosttySurface(id: UUID) -> SurfaceView?
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,22 @@ extension Ghostty {
|
||||
case xray
|
||||
case custom
|
||||
case customStyle = "custom-style"
|
||||
|
||||
/// Bundled asset name for built-in icons
|
||||
var assetName: String? {
|
||||
switch self {
|
||||
case .official: return nil
|
||||
case .blueprint: return "BlueprintImage"
|
||||
case .chalkboard: return "ChalkboardImage"
|
||||
case .microchip: return "MicrochipImage"
|
||||
case .glass: return "GlassImage"
|
||||
case .holographic: return "HolographicImage"
|
||||
case .paper: return "PaperImage"
|
||||
case .retro: return "RetroImage"
|
||||
case .xray: return "XrayImage"
|
||||
case .custom, .customStyle: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macos-icon-frame
|
||||
@@ -475,6 +491,10 @@ extension Ghostty.Notification {
|
||||
static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence")
|
||||
static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence")
|
||||
static let KeySequenceKey = didContinueKeySequence.rawValue + ".key"
|
||||
|
||||
/// Notifications related to key tables
|
||||
static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable")
|
||||
static let KeyTableKey = didChangeKeyTable.rawValue + ".action"
|
||||
}
|
||||
|
||||
// Make the input enum hashable.
|
||||
|
||||
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A preference key that propagates the ID of the SurfaceView currently being dragged,
|
||||
/// or nil if no surface is being dragged.
|
||||
struct DraggingSurfaceKey: PreferenceKey {
|
||||
static var defaultValue: SurfaceView.ID? = nil
|
||||
|
||||
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
|
||||
///
|
||||
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
|
||||
/// of terminal surfaces within split views. When the user drags this view, it initiates
|
||||
/// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing
|
||||
/// drop targets to identify which surface is being moved.
|
||||
///
|
||||
/// The view also publishes the dragging state via `DraggingSurfaceKey` preference,
|
||||
/// enabling parent views to react to ongoing drag operations.
|
||||
struct SurfaceDragSource: View {
|
||||
/// The surface view that will be dragged.
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
/// Binding that reflects whether a drag session is currently active.
|
||||
@Binding var isDragging: Bool
|
||||
|
||||
/// Binding that reflects whether the mouse is hovering over this view.
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
var body: some View {
|
||||
SurfaceDragSourceViewRepresentable(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering)
|
||||
.preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// An NSViewRepresentable that provides AppKit-based drag source functionality.
|
||||
/// This gives us control over the drag lifecycle, particularly detecting drag start.
|
||||
fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable {
|
||||
let surfaceView: SurfaceView
|
||||
@Binding var isDragging: Bool
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
func makeNSView(context: Context) -> SurfaceDragSourceView {
|
||||
let view = SurfaceDragSourceView()
|
||||
view.surfaceView = surfaceView
|
||||
view.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
view.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
|
||||
nsView.surfaceView = surfaceView
|
||||
nsView.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
nsView.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying NSView that handles drag operations.
|
||||
///
|
||||
/// This view manages mouse tracking and drag initiation for surface reordering.
|
||||
/// It uses a local event loop to detect drag gestures and initiates an
|
||||
/// `NSDraggingSession` when the user drags beyond the threshold distance.
|
||||
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
|
||||
/// Scale factor applied to the surface snapshot for the drag preview image.
|
||||
private static let previewScale: CGFloat = 0.2
|
||||
|
||||
/// The surface view that will be dragged. Its UUID is encoded into the
|
||||
/// pasteboard for drop targets to identify which surface is being moved.
|
||||
var surfaceView: SurfaceView?
|
||||
|
||||
/// Callback invoked when the drag state changes. Called with `true` when
|
||||
/// a drag session begins, and `false` when it ends (completed or cancelled).
|
||||
var onDragStateChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Callback invoked when the mouse enters or exits this view's bounds.
|
||||
/// Used to update the hover state for visual feedback in the parent view.
|
||||
var onHoverChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Whether we are currently in a mouse tracking loop (between mouseDown
|
||||
/// and either mouseUp or drag initiation). Used to determine cursor state.
|
||||
private var isTracking: Bool = false
|
||||
|
||||
/// Local event monitor to detect escape key presses during drag.
|
||||
private var escapeMonitor: Any?
|
||||
|
||||
/// Whether the current drag was cancelled by pressing escape.
|
||||
private var dragCancelledByEscape: Bool = false
|
||||
|
||||
deinit {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
// Ensure this view gets the mouse event before window dragging handlers
|
||||
return true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
// Consume the mouseDown event to prevent it from propagating to the
|
||||
// window's drag handler. This fixes issue #10110 where grab handles
|
||||
// would drag the window instead of initiating pane drags.
|
||||
// Don't call super - the drag will be initiated in mouseDragged.
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
|
||||
// To update our tracking area we just recreate it all.
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
|
||||
// Add our tracking area for mouse events
|
||||
addTrackingArea(NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: [.mouseEnteredAndExited, .activeInActiveApp],
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
))
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
onHoverChanged?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
onHoverChanged?(false)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard !isTracking, let surfaceView = surfaceView else { return }
|
||||
|
||||
// Create our dragging item from our transferable
|
||||
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
|
||||
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
|
||||
// Create a scaled preview image from the surface snapshot
|
||||
if let snapshot = surfaceView.asImage {
|
||||
let imageSize = NSSize(
|
||||
width: snapshot.size.width * Self.previewScale,
|
||||
height: snapshot.size.height * Self.previewScale
|
||||
)
|
||||
let scaledImage = NSImage(size: imageSize)
|
||||
scaledImage.lockFocus()
|
||||
snapshot.draw(
|
||||
in: NSRect(origin: .zero, size: imageSize),
|
||||
from: NSRect(origin: .zero, size: snapshot.size),
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
)
|
||||
scaledImage.unlockFocus()
|
||||
|
||||
// Position the drag image so the mouse is at the center of the image.
|
||||
// I personally like the top middle or top left corner best but
|
||||
// this matches macOS native tab dragging behavior (at least, as of
|
||||
// macOS 26.2 on Dec 29, 2025).
|
||||
let mouseLocation = convert(event.locationInWindow, from: nil)
|
||||
let origin = NSPoint(
|
||||
x: mouseLocation.x - imageSize.width / 2,
|
||||
y: mouseLocation.y - imageSize.height / 2
|
||||
)
|
||||
item.setDraggingFrame(
|
||||
NSRect(origin: origin, size: imageSize),
|
||||
contents: scaledImage
|
||||
)
|
||||
}
|
||||
|
||||
onDragStateChanged?(true)
|
||||
let session = beginDraggingSession(with: [item], event: event, source: self)
|
||||
|
||||
// We need to disable this so that endedAt happens immediately for our
|
||||
// drags outside of any targets.
|
||||
session.animatesToStartingPositionsOnCancelOrFail = false
|
||||
}
|
||||
|
||||
// MARK: NSDraggingSource
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
sourceOperationMaskFor context: NSDraggingContext
|
||||
) -> NSDragOperation {
|
||||
return context == .withinApplication ? .move : []
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
willBeginAt screenPoint: NSPoint
|
||||
) {
|
||||
isTracking = true
|
||||
|
||||
// Reset our escape tracking
|
||||
dragCancelledByEscape = false
|
||||
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
if event.keyCode == 53 { // Escape key
|
||||
self?.dragCancelledByEscape = true
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
movedTo screenPoint: NSPoint
|
||||
) {
|
||||
NSCursor.closedHand.set()
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
endedAt screenPoint: NSPoint,
|
||||
operation: NSDragOperation
|
||||
) {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
self.escapeMonitor = nil
|
||||
}
|
||||
|
||||
if operation == [] && !dragCancelledByEscape {
|
||||
let endsInWindow = NSApplication.shared.windows.contains { window in
|
||||
window.isVisible && window.frame.contains(screenPoint)
|
||||
}
|
||||
if !endsInWindow {
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: surfaceView,
|
||||
userInfo: [Foundation.Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey: screenPoint]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isTracking = false
|
||||
onDragStateChanged?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when a surface drag session ends with no operation (the drag was
|
||||
/// released outside a valid drop target) and was not cancelled by the user
|
||||
/// pressing escape. The notification's object is the SurfaceView that was dragged.
|
||||
static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget")
|
||||
|
||||
/// Key for the screen point where the drag ended in the userInfo dictionary.
|
||||
static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint"
|
||||
}
|
||||
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A grab handle overlay at the top of the surface for dragging the window.
|
||||
/// Only appears when hovering in the top region of the surface.
|
||||
struct SurfaceGrabHandle: View {
|
||||
private let handleHeight: CGFloat = 10
|
||||
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var isDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0))
|
||||
.frame(height: handleHeight)
|
||||
.overlay(alignment: .center) {
|
||||
if isHovering || isDragging {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
SurfaceDragSource(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,18 +120,20 @@ class SurfaceScrollView: NSView {
|
||||
self?.handleScrollerStyleChange()
|
||||
})
|
||||
|
||||
// Listen for frame change events. See the docstring for
|
||||
// handleFrameChange for why this is necessary.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChange(notification)
|
||||
})
|
||||
// Listen for frame change events on macOS 26.0. See the docstring for
|
||||
// handleFrameChangeForNSScrollPocket for why this is necessary.
|
||||
if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) {
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChangeForNSScrollPocket(notification)
|
||||
})
|
||||
}}
|
||||
|
||||
// Listen for derived config changes to update scrollbar settings live
|
||||
surfaceView.$derivedConfig
|
||||
@@ -328,7 +330,10 @@ class SurfaceScrollView: NSView {
|
||||
/// and reset their frame to zero.
|
||||
///
|
||||
/// See also https://developer.apple.com/forums/thread/798392.
|
||||
private func handleFrameChange(_ notification: Notification) {
|
||||
///
|
||||
/// This bug is only present in macOS 26.0.
|
||||
@available(macOS, introduced: 26.0, obsoleted: 26.1)
|
||||
private func handleFrameChangeForNSScrollPocket(_ notification: Notification) {
|
||||
guard let window = window as? HiddenTitlebarTerminalWindow else { return }
|
||||
guard !window.styleMask.contains(.fullScreen) else { return }
|
||||
guard let view = notification.object as? NSView else { return }
|
||||
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Ghostty.SurfaceView {
|
||||
#if canImport(AppKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: NSImage? {
|
||||
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
|
||||
return nil
|
||||
}
|
||||
cacheDisplay(in: bounds, to: bitmapRep)
|
||||
let image = NSImage(size: bounds.size)
|
||||
image.addRepresentation(bitmapRep)
|
||||
return image
|
||||
}
|
||||
#elseif canImport(UIKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: UIImage? {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
return renderer.image { _ in
|
||||
drawHierarchy(in: bounds, afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Conformance to `Transferable` enables drag-and-drop.
|
||||
extension Ghostty.SurfaceView: Transferable {
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .ghosttySurfaceId) { surface in
|
||||
withUnsafeBytes(of: surface.id.uuid) { Data($0) }
|
||||
} importing: { data in
|
||||
guard data.count == 16 else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
let uuid = data.withUnsafeBytes {
|
||||
$0.load(as: UUID.self)
|
||||
}
|
||||
|
||||
guard let imported = await Self.find(uuid: uuid) else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
return imported
|
||||
}
|
||||
}
|
||||
|
||||
enum TransferError: Error {
|
||||
case invalidData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func find(uuid: UUID) -> Self? {
|
||||
#if canImport(AppKit)
|
||||
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
|
||||
return del.ghosttySurface(id: uuid) as? Self
|
||||
#elseif canImport(UIKit)
|
||||
// We should be able to use UIApplication here.
|
||||
return nil
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension UTType {
|
||||
/// A format that encodes the bare UUID only for the surface. This can be used if you have
|
||||
/// a way to look up a surface by ID.
|
||||
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
extension NSPasteboard.PasteboardType {
|
||||
/// Pasteboard type for dragging surface IDs.
|
||||
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
|
||||
}
|
||||
#endif
|
||||
@@ -123,31 +123,11 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
// If we are in the middle of a key sequence, then we show a visual element. We only
|
||||
// support this on macOS currently although in theory we can support mobile with keyboards!
|
||||
if !surfaceView.keySequence.isEmpty {
|
||||
let padding: CGFloat = 5
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Text(verbatim: "Pending Key Sequence:")
|
||||
ForEach(0..<surfaceView.keySequence.count, id: \.description) { index in
|
||||
let key = surfaceView.keySequence[index]
|
||||
Text(verbatim: key.description)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color(NSColor.selectedTextBackgroundColor))
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.background)
|
||||
}
|
||||
}
|
||||
// Show key state indicator for active key tables and/or pending key sequences
|
||||
KeyStateIndicator(
|
||||
keyTables: surfaceView.keyTables,
|
||||
keySequence: surfaceView.keySequence
|
||||
)
|
||||
#endif
|
||||
|
||||
// If we have a URL from hovering a link, we show that.
|
||||
@@ -244,6 +224,14 @@ extension Ghostty {
|
||||
.opacity(overlayOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
// Grab handle for dragging the window. We want this to appear at the very
|
||||
// top Z-index os it isn't faded by the unfocused overlay.
|
||||
//
|
||||
// This is disabled except on macOS because it uses AppKit drag/drop APIs.
|
||||
SurfaceGrabHandle(surfaceView: surfaceView)
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -443,7 +431,12 @@ extension Ghostty {
|
||||
}
|
||||
#if canImport(AppKit)
|
||||
.onExitCommand {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
if searchState.needle.isEmpty {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
onClose()
|
||||
} else {
|
||||
Ghostty.moveFocus(to: surfaceView)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.backport.onKeyPress(.return) { modifiers in
|
||||
@@ -487,7 +480,9 @@ extension Ghostty {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
|
||||
guard notification.object as? SurfaceView === surfaceView else { return }
|
||||
isSearchFieldFocused = true
|
||||
DispatchQueue.main.async {
|
||||
isSearchFieldFocused = true
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { barGeo in
|
||||
@@ -658,6 +653,9 @@ extension Ghostty {
|
||||
/// Wait after the command
|
||||
var waitAfterCommand: Bool = false
|
||||
|
||||
/// Context for surface creation
|
||||
var context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_WINDOW
|
||||
|
||||
init() {}
|
||||
|
||||
init(from config: ghostty_surface_config_s) {
|
||||
@@ -679,6 +677,7 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.context = config.context
|
||||
}
|
||||
|
||||
/// Provides a C-compatible ghostty configuration within a closure. The configuration
|
||||
@@ -712,6 +711,9 @@ extension Ghostty {
|
||||
// Set wait after command
|
||||
config.wait_after_command = waitAfterCommand
|
||||
|
||||
// Set context
|
||||
config.context = context
|
||||
|
||||
// Use withCString to ensure strings remain valid for the duration of the closure
|
||||
return try workingDirectory.withCString { cWorkingDir in
|
||||
config.working_directory = cWorkingDir
|
||||
@@ -752,6 +754,226 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
/// Floating indicator that shows active key tables and pending key sequences.
|
||||
/// Displayed as a compact draggable pill that can be positioned at the top or bottom.
|
||||
struct KeyStateIndicator: View {
|
||||
let keyTables: [String]
|
||||
let keySequence: [KeyboardShortcut]
|
||||
|
||||
@State private var isShowingPopover = false
|
||||
@State private var position: Position = .bottom
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
@State private var isDragging = false
|
||||
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
enum Position {
|
||||
case top, bottom
|
||||
|
||||
var alignment: Alignment {
|
||||
switch self {
|
||||
case .top: return .top
|
||||
case .bottom: return .bottom
|
||||
}
|
||||
}
|
||||
|
||||
var popoverEdge: Edge {
|
||||
switch self {
|
||||
case .top: return .top
|
||||
case .bottom: return .bottom
|
||||
}
|
||||
}
|
||||
|
||||
var transitionEdge: Edge {
|
||||
popoverEdge
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !keyTables.isEmpty || !keySequence.isEmpty {
|
||||
content
|
||||
.backport.pointerStyle(!keyTables.isEmpty ? .link : nil)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: position.transitionEdge).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count)
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
indicatorContent
|
||||
.offset(dragOffset)
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment)
|
||||
.highPriorityGesture(
|
||||
DragGesture(coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
dragOffset = CGSize(width: 0, height: value.translation.height)
|
||||
}
|
||||
.onEnded { value in
|
||||
isDragging = false
|
||||
let dragThreshold: CGFloat = 50
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
if position == .bottom && value.translation.height < -dragThreshold {
|
||||
position = .top
|
||||
} else if position == .top && value.translation.height > dragThreshold {
|
||||
position = .bottom
|
||||
}
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorContent: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
// Key table indicator
|
||||
if !keyTables.isEmpty {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "keyboard.badge.ellipsis")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Show table stack with arrows between them
|
||||
ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in
|
||||
if index > 0 {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(verbatim: table)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator when both are active
|
||||
if !keyTables.isEmpty && !keySequence.isEmpty {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
}
|
||||
|
||||
// Key sequence indicator
|
||||
if !keySequence.isEmpty {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in
|
||||
KeyCap(key.description)
|
||||
}
|
||||
|
||||
// Animated ellipsis to indicate waiting for next key
|
||||
PendingIndicator(paused: isDragging)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
.backport.pointerStyle(.link)
|
||||
.popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !keyTables.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Key Table", systemImage: "keyboard.badge.ellipsis")
|
||||
.font(.headline)
|
||||
Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !keyTables.isEmpty && !keySequence.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
|
||||
if !keySequence.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Key Sequence", systemImage: "character.cursor.ibeam")
|
||||
.font(.headline)
|
||||
Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: 400)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.onTapGesture {
|
||||
isShowingPopover.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/// A small keycap-style view for displaying keyboard shortcuts
|
||||
struct KeyCap: View {
|
||||
let text: String
|
||||
|
||||
init(_ text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: text)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated dots to indicate waiting for the next key
|
||||
struct PendingIndicator: View {
|
||||
@State private var animationPhase: Double = 0
|
||||
let paused: Bool
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: paused)) { context in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(Color.secondary)
|
||||
.frame(width: 4, height: 4)
|
||||
.opacity(dotOpacity(for: index))
|
||||
}
|
||||
}
|
||||
.onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in
|
||||
animationPhase = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dotOpacity(for index: Int) -> Double {
|
||||
let phase = animationPhase
|
||||
let offset = Double(index) / 3.0
|
||||
let wave = sin((phase + offset) * .pi * 2)
|
||||
return 0.3 + 0.7 * ((wave + 1) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
|
||||
struct BellBorderOverlay: View {
|
||||
let bell: Bool
|
||||
@@ -9,7 +9,7 @@ extension Ghostty {
|
||||
/// The NSView implementation for a terminal surface.
|
||||
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
|
||||
typealias ID = UUID
|
||||
|
||||
|
||||
/// Unique ID per surface
|
||||
let id: UUID
|
||||
|
||||
@@ -44,14 +44,14 @@ extension Ghostty {
|
||||
|
||||
// The hovered URL string
|
||||
@Published var hoverUrl: String? = nil
|
||||
|
||||
|
||||
// The progress report (if any)
|
||||
@Published var progressReport: Action.ProgressReport? = nil {
|
||||
didSet {
|
||||
// Cancel any existing timer
|
||||
progressReportTimer?.invalidate()
|
||||
progressReportTimer = nil
|
||||
|
||||
|
||||
// If we have a new progress report, start a timer to remove it after 15 seconds
|
||||
if progressReport != nil {
|
||||
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
|
||||
@@ -65,6 +65,9 @@ extension Ghostty {
|
||||
// The currently active key sequence. The sequence is not active if this is empty.
|
||||
@Published var keySequence: [KeyboardShortcut] = []
|
||||
|
||||
// The currently active key tables. Empty if no tables are active.
|
||||
@Published var keyTables: [String] = []
|
||||
|
||||
// The current search state. When non-nil, the search overlay should be shown.
|
||||
@Published var searchState: SearchState? = nil {
|
||||
didSet {
|
||||
@@ -98,7 +101,7 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cancellable for search state needle changes
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
|
||||
@@ -216,7 +219,7 @@ extension Ghostty {
|
||||
|
||||
// A timer to fallback to ghost emoji if no title is set within the grace period
|
||||
private var titleFallbackTimer: Timer?
|
||||
|
||||
|
||||
// Timer to remove progress report after 15 seconds
|
||||
private var progressReportTimer: Timer?
|
||||
|
||||
@@ -324,6 +327,11 @@ extension Ghostty {
|
||||
selector: #selector(ghosttyDidEndKeySequence),
|
||||
name: Ghostty.Notification.didEndKeySequence,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyDidChangeKeyTable),
|
||||
name: Ghostty.Notification.didChangeKeyTable,
|
||||
object: self)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
@@ -410,7 +418,7 @@ extension Ghostty {
|
||||
// Remove any notifications associated with this surface
|
||||
let identifiers = Array(self.notificationIdentifiers)
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
|
||||
|
||||
// Cancel progress report timer
|
||||
progressReportTimer?.invalidate()
|
||||
}
|
||||
@@ -547,16 +555,16 @@ extension Ghostty {
|
||||
// Add buttons
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
|
||||
// Make the text field the first responder so it gets focus
|
||||
alert.window.initialFirstResponder = textField
|
||||
|
||||
|
||||
let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
// Check if the user clicked "OK"
|
||||
guard response == .alertFirstButtonReturn else { return }
|
||||
|
||||
|
||||
// Get the input text
|
||||
let newTitle = textField.stringValue
|
||||
if newTitle.isEmpty {
|
||||
@@ -680,6 +688,22 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) {
|
||||
guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .activate(let name):
|
||||
self.keyTables.append(name)
|
||||
case .deactivate:
|
||||
_ = self.keyTables.popLast()
|
||||
case .deactivateAll:
|
||||
self.keyTables.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) {
|
||||
// Get our managed configuration object out
|
||||
guard let config = notification.userInfo?[
|
||||
@@ -836,16 +860,16 @@ extension Ghostty {
|
||||
|
||||
override func otherMouseDown(with event: NSEvent) {
|
||||
guard let surface = self.surface else { return }
|
||||
guard event.buttonNumber == 2 else { return }
|
||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods)
|
||||
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods)
|
||||
}
|
||||
|
||||
override func otherMouseUp(with event: NSEvent) {
|
||||
guard let surface = self.surface else { return }
|
||||
guard event.buttonNumber == 2 else { return }
|
||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods)
|
||||
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
|
||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods)
|
||||
}
|
||||
|
||||
|
||||
@@ -964,7 +988,7 @@ extension Ghostty {
|
||||
var x = event.scrollingDeltaX
|
||||
var y = event.scrollingDeltaY
|
||||
let precision = event.hasPreciseScrollingDeltas
|
||||
|
||||
|
||||
if precision {
|
||||
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
|
||||
x *= 2;
|
||||
@@ -1157,17 +1181,10 @@ extension Ghostty {
|
||||
|
||||
/// Special case handling for some control keys
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
switch (event.type) {
|
||||
case .keyDown:
|
||||
// Continue, we care about key down events
|
||||
break
|
||||
|
||||
default:
|
||||
// Any other key event we don't care about. I don't think its even
|
||||
// possible to receive any other event type.
|
||||
return false
|
||||
}
|
||||
|
||||
// We only care about key down events. It might not even be possible
|
||||
// to receive any other event type here.
|
||||
guard event.type == .keyDown else { return false }
|
||||
|
||||
// Only process events if we're focused. Some key events like C-/ macOS
|
||||
// appears to send to the first view in the hierarchy rather than the
|
||||
// the first responder (I don't know why). This prevents us from handling it.
|
||||
@@ -1177,18 +1194,35 @@ extension Ghostty {
|
||||
if (!focused) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If this event as-is would result in a key binding then we send it.
|
||||
if let surface {
|
||||
|
||||
// Get information about if this is a binding.
|
||||
let bindingFlags = surfaceModel.flatMap { surface in
|
||||
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
|
||||
let match = (event.characters ?? "").withCString { ptr in
|
||||
return (event.characters ?? "").withCString { ptr in
|
||||
ghosttyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(surface, ghosttyEvent)
|
||||
return surface.keyIsBinding(ghosttyEvent)
|
||||
}
|
||||
if match {
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
// If this is a binding then we want to perform it.
|
||||
if let bindingFlags {
|
||||
// Attempt to trigger a menu item for this key binding. We only do this if:
|
||||
// - We're not in a key sequence or table (those are separate bindings)
|
||||
// - The binding is NOT `all` (menu uses FirstResponder chain)
|
||||
// - The binding is NOT `performable` (menu will always consume)
|
||||
// - The binding is `consumed` (unconsumed bindings should pass through
|
||||
// to the terminal, so we must not intercept them for the menu)
|
||||
if keySequence.isEmpty,
|
||||
keyTables.isEmpty,
|
||||
bindingFlags.isDisjoint(with: [.all, .performable]),
|
||||
bindingFlags.contains(.consumed) {
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
self.keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
@@ -1326,7 +1360,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.
|
||||
@@ -1485,7 +1519,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func find(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "start_search"
|
||||
@@ -1493,7 +1527,23 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func selectionForFind(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "search_selection"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func scrollToSelection(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "scroll_to_selection"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func findNext(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "search:next"
|
||||
@@ -1509,7 +1559,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func findHide(_ sender: Any?) {
|
||||
guard let surface = self.surface else { return }
|
||||
let action = "end_search"
|
||||
@@ -1569,7 +1619,7 @@ extension Ghostty {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@IBAction func changeTitle(_ sender: Any) {
|
||||
promptTitle()
|
||||
}
|
||||
@@ -1630,6 +1680,7 @@ extension Ghostty {
|
||||
struct DerivedConfig {
|
||||
let backgroundColor: Color
|
||||
let backgroundOpacity: Double
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
let macosWindowShadow: Bool
|
||||
let windowTitleFontFamily: String?
|
||||
let windowAppearance: NSAppearance?
|
||||
@@ -1638,6 +1689,7 @@ extension Ghostty {
|
||||
init() {
|
||||
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
||||
self.backgroundOpacity = 1
|
||||
self.backgroundBlur = .disabled
|
||||
self.macosWindowShadow = true
|
||||
self.windowTitleFontFamily = nil
|
||||
self.windowAppearance = nil
|
||||
@@ -1647,6 +1699,7 @@ extension Ghostty {
|
||||
init(_ config: Ghostty.Config) {
|
||||
self.backgroundColor = config.backgroundColor
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.macosWindowShadow = config.macosWindowShadow
|
||||
self.windowTitleFontFamily = config.windowTitleFontFamily
|
||||
self.windowAppearance = .init(ghosttyConfig: config)
|
||||
@@ -1679,7 +1732,7 @@ extension Ghostty {
|
||||
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false
|
||||
|
||||
self.init(app, baseConfig: config, uuid: uuid)
|
||||
|
||||
|
||||
// Restore the saved title after initialization
|
||||
if let title = savedTitle {
|
||||
self.title = title
|
||||
@@ -1896,6 +1949,17 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
||||
return
|
||||
}
|
||||
|
||||
guard let surfaceModel else { return }
|
||||
// Process MacOS native scroll events
|
||||
switch selector {
|
||||
case #selector(moveToBeginningOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_top")
|
||||
case #selector(moveToEndOfDocument(_:)):
|
||||
_ = surfaceModel.perform(action: "scroll_to_bottom")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
print("SEL: \(selector)")
|
||||
}
|
||||
|
||||
@@ -1936,14 +2000,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
|
||||
// but get requested an image (we can't handle that at the time of writing this),
|
||||
// so we must bubble up.
|
||||
|
||||
|
||||
// Types we can receive
|
||||
let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
|
||||
|
||||
|
||||
// Types that we can send. Currently the same as receivable but I'm separating
|
||||
// this out so we can modify this in the future.
|
||||
let sendable: [NSPasteboard.PasteboardType] = receivable
|
||||
|
||||
|
||||
// The sendable types that require a selection (currently all)
|
||||
let sendableRequiresSelection = sendable
|
||||
|
||||
@@ -1960,7 +2024,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||
return super.validRequestor(forSendType: sendType, returnType: returnType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -2006,7 +2070,7 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
|
||||
let pb = NSPasteboard.ghosttySelection
|
||||
guard let str = pb.getOpinionatedStringContents() else { return false }
|
||||
return !str.isEmpty
|
||||
|
||||
|
||||
case #selector(findHide):
|
||||
return searchState != nil
|
||||
|
||||
@@ -2111,7 +2175,7 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilitySelectedTextRange() -> NSRange {
|
||||
return selectedRange()
|
||||
}
|
||||
|
||||
|
||||
/// Returns the currently selected text as a string.
|
||||
/// This allows assistive technologies to read the selected content.
|
||||
override func accessibilitySelectedText() -> String? {
|
||||
@@ -2125,21 +2189,21 @@ extension Ghostty.SurfaceView {
|
||||
let str = String(cString: text.text)
|
||||
return str.isEmpty ? nil : str
|
||||
}
|
||||
|
||||
|
||||
/// Returns the number of characters in the terminal content.
|
||||
/// This helps assistive technologies understand the size of the content.
|
||||
override func accessibilityNumberOfCharacters() -> Int {
|
||||
let content = cachedScreenContents.get()
|
||||
return content.count
|
||||
}
|
||||
|
||||
|
||||
/// Returns the visible character range for the terminal.
|
||||
/// For terminals, we typically show all content as visible.
|
||||
override func accessibilityVisibleCharacterRange() -> NSRange {
|
||||
let content = cachedScreenContents.get()
|
||||
return NSRange(location: 0, length: content.count)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the line number for a given character index.
|
||||
/// This helps assistive technologies navigate by line.
|
||||
override func accessibilityLine(for index: Int) -> Int {
|
||||
@@ -2147,7 +2211,7 @@ extension Ghostty.SurfaceView {
|
||||
let substring = String(content.prefix(index))
|
||||
return substring.components(separatedBy: .newlines).count - 1
|
||||
}
|
||||
|
||||
|
||||
/// Returns a substring for the given range.
|
||||
/// This allows assistive technologies to read specific portions of the content.
|
||||
override func accessibilityString(for range: NSRange) -> String? {
|
||||
@@ -2155,7 +2219,7 @@ extension Ghostty.SurfaceView {
|
||||
guard let swiftRange = Range(range, in: content) else { return nil }
|
||||
return String(content[swiftRange])
|
||||
}
|
||||
|
||||
|
||||
/// Returns an attributed string for the given range.
|
||||
///
|
||||
/// Note: right now this only applies font information. One day it'd be nice to extend
|
||||
@@ -2166,9 +2230,9 @@ extension Ghostty.SurfaceView {
|
||||
override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
|
||||
guard let surface = self.surface else { return nil }
|
||||
guard let plainString = accessibilityString(for: range) else { return nil }
|
||||
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
|
||||
|
||||
// Try to get the font from the surface
|
||||
if let fontRaw = ghostty_surface_quicklook_font(surface) {
|
||||
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
|
||||
@@ -2178,6 +2242,7 @@ extension Ghostty.SurfaceView {
|
||||
|
||||
return NSAttributedString(string: plainString, attributes: attributes)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Caches a value for some period of time, evicting it automatically when that time expires.
|
||||
@@ -4,8 +4,10 @@ import GhosttyKit
|
||||
extension Ghostty {
|
||||
/// The UIView implementation for a terminal surface.
|
||||
class SurfaceView: UIView, ObservableObject {
|
||||
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
|
||||
@@ -43,7 +45,10 @@ extension Ghostty {
|
||||
|
||||
// The current search state. When non-nil, the search overlay should be shown.
|
||||
@Published var searchState: SearchState? = nil
|
||||
|
||||
|
||||
// The currently active key tables. Empty if no tables are active.
|
||||
@Published var keyTables: [String] = []
|
||||
|
||||
/// True when the surface is in readonly mode.
|
||||
@Published private(set) var readonly: Bool = false
|
||||
|
||||
@@ -60,7 +65,7 @@ extension Ghostty {
|
||||
private(set) var surface: ghostty_surface_t?
|
||||
|
||||
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
|
||||
self.uuid = uuid ?? .init()
|
||||
self.id = uuid ?? .init()
|
||||
|
||||
// Initialize with some default frame size. The important thing is that this
|
||||
// is non-zero so that our layer bounds are non-zero so that our renderer
|
||||
@@ -1,19 +0,0 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
struct DraggableWindowView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> DraggableWindowNSView {
|
||||
return DraggableWindowNSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DraggableWindowNSView, context: Context) {
|
||||
// No need to update anything here
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableWindowNSView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window = self.window else { return }
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,73 @@ extension NSWindow {
|
||||
return CGWindowID(windowNumber)
|
||||
}
|
||||
|
||||
/// True if this is the first window in the tab group.
|
||||
var isFirstWindowInTabGroup: Bool {
|
||||
guard let firstWindow = tabGroup?.windows.first else { return true }
|
||||
return firstWindow === self
|
||||
}
|
||||
|
||||
/// Adjusts the window origin if necessary to ensure the window remains visible on screen.
|
||||
/// Adjusts the window frame if necessary to ensure the window remains visible on screen.
|
||||
/// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen).
|
||||
func constrainToScreen() {
|
||||
guard let screen = screen ?? NSScreen.main else { return }
|
||||
let visibleFrame = screen.visibleFrame
|
||||
var windowFrame = frame
|
||||
|
||||
windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width)
|
||||
windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height)
|
||||
|
||||
windowFrame.origin.x = max(visibleFrame.minX,
|
||||
min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width))
|
||||
windowFrame.origin.y = max(visibleFrame.minY,
|
||||
min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height))
|
||||
|
||||
if windowFrame.origin != frame.origin {
|
||||
setFrameOrigin(windowFrame.origin)
|
||||
if windowFrame != frame {
|
||||
setFrame(windowFrame, display: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Native Tabbing
|
||||
|
||||
extension NSWindow {
|
||||
/// True if this is the first window in the tab group.
|
||||
var isFirstWindowInTabGroup: Bool {
|
||||
guard let firstWindow = tabGroup?.windows.first else { return true }
|
||||
return firstWindow === self
|
||||
}
|
||||
}
|
||||
|
||||
/// Native tabbing private API usage. :(
|
||||
extension NSWindow {
|
||||
var titlebarView: NSView? {
|
||||
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||
guard let themeFrameView = contentView?.rootView else { return nil }
|
||||
guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil }
|
||||
return themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||
}
|
||||
|
||||
/// Returns the [private] NSTabBar view, if it exists.
|
||||
var tabBarView: NSView? {
|
||||
titlebarView?.firstDescendant(withClassName: "NSTabBar")
|
||||
}
|
||||
|
||||
/// Returns the index of the tab button at the given screen point, if any.
|
||||
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
|
||||
guard let tabBarView else { return nil }
|
||||
let locationInWindow = convertPoint(fromScreen: screenPoint)
|
||||
let locationInTabBar = tabBarView.convert(locationInWindow, from: nil)
|
||||
guard tabBarView.bounds.contains(locationInTabBar) else { return nil }
|
||||
|
||||
// Find all tab buttons and sort by x position to get visual order.
|
||||
// The view hierarchy order doesn't match the visual tab order.
|
||||
let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton")
|
||||
.sorted { $0.frame.origin.x < $1.frame.origin.x }
|
||||
|
||||
for (index, tabItemView) in tabItemViews.enumerated() {
|
||||
let locationInTab = tabItemView.convert(locationInWindow, from: nil)
|
||||
if tabItemView.bounds.contains(locationInTab) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import AppKit
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension Transferable {
|
||||
/// Converts this Transferable to an NSPasteboardItem with lazy data loading.
|
||||
/// Data is only fetched when the pasteboard consumer requests it. This allows
|
||||
/// bridging a Transferable to NSDraggingSource.
|
||||
func pasteboardItem() -> NSPasteboardItem? {
|
||||
let itemProvider = NSItemProvider()
|
||||
itemProvider.register(self)
|
||||
|
||||
let types = itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) }
|
||||
guard !types.isEmpty else { return nil }
|
||||
|
||||
let item = NSPasteboardItem()
|
||||
let dataProvider = TransferableDataProvider(itemProvider: itemProvider)
|
||||
let pasteboardTypes = types.map { NSPasteboard.PasteboardType($0.identifier) }
|
||||
item.setDataProvider(dataProvider, forTypes: pasteboardTypes)
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProvider {
|
||||
private let itemProvider: NSItemProvider
|
||||
|
||||
init(itemProvider: NSItemProvider) {
|
||||
self.itemProvider = itemProvider
|
||||
super.init()
|
||||
}
|
||||
|
||||
func pasteboard(
|
||||
_ pasteboard: NSPasteboard?,
|
||||
item: NSPasteboardItem,
|
||||
provideDataForType type: NSPasteboard.PasteboardType
|
||||
) {
|
||||
// NSPasteboardItemDataProvider requires synchronous data return, but
|
||||
// NSItemProvider.loadDataRepresentation is async. We use a semaphore
|
||||
// to block until the async load completes. This is safe because AppKit
|
||||
// calls this method on a background thread during drag operations.
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
var result: Data?
|
||||
itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in
|
||||
result = data
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
// Wait for the data to load
|
||||
semaphore.wait()
|
||||
|
||||
// Set it. I honestly don't know what happens here if this fails.
|
||||
if let data = result {
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
|
||||
class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
var fullscreenMode: FullscreenMode { .nonNative }
|
||||
|
||||
|
||||
// Non-native fullscreen never supports tabs because tabs require
|
||||
// the "titled" style and we don't have it for non-native fullscreen.
|
||||
var supportsTabs: Bool { false }
|
||||
@@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
// Being untitled let's our content take up the full frame.
|
||||
window.styleMask.remove(.titled)
|
||||
|
||||
// We dont' want the non-native fullscreen window to be resizable
|
||||
// We don't want the non-native fullscreen window to be resizable
|
||||
// from the edges.
|
||||
window.styleMask.remove(.resizable)
|
||||
|
||||
@@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
if let window = window as? TerminalWindow, window.isTabBar(c) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
|
||||
window.addTitlebarAccessoryViewController(c)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
// Removing "titled" also clears our toolbar
|
||||
window.toolbar = savedState.toolbar
|
||||
window.toolbarStyle = savedState.toolbarStyle
|
||||
|
||||
|
||||
// If the window was previously in a tab group that isn't empty now,
|
||||
// we re-add it. We have to do this because our process of doing non-native
|
||||
// fullscreen removes the window from the tab group.
|
||||
@@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
|
||||
self.toolbar = window.toolbar
|
||||
self.toolbarStyle = window.toolbarStyle
|
||||
self.dock = window.screen?.hasDock ?? false
|
||||
|
||||
|
||||
self.titlebarAccessoryViewControllers = if (window.hasTitleBar) {
|
||||
// Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash.
|
||||
window.titlebarAccessoryViewControllers
|
||||
|
||||
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Testing
|
||||
import AppKit
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
@testable import Ghostty
|
||||
|
||||
struct TransferablePasteboardTests {
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// A simple Transferable type for testing pasteboard conversion.
|
||||
private struct DummyTransferable: Transferable, Equatable {
|
||||
let payload: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.payload.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
let string = String(data: data, encoding: .utf8)!
|
||||
return DummyTransferable(payload: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transferable type that registers multiple content types.
|
||||
private struct MultiTypeTransferable: Transferable {
|
||||
let text: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
DataRepresentation(contentType: .plainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Basic Functionality
|
||||
|
||||
@Test func pasteboardItemIsCreated() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
let item = transferable.pasteboardItem()
|
||||
#expect(item != nil)
|
||||
}
|
||||
|
||||
@Test func pasteboardItemContainsExpectedType() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
#expect(item.types.contains(expectedType))
|
||||
}
|
||||
|
||||
@Test func pasteboardItemProvidesCorrectData() {
|
||||
let transferable = DummyTransferable(payload: "test data")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
|
||||
// Write to a pasteboard to trigger data provider
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Read back the data
|
||||
guard let data = pasteboard.data(forType: pasteboardType) else {
|
||||
Issue.record("Expected data to be available on pasteboard")
|
||||
return
|
||||
}
|
||||
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
#expect(string == "test data")
|
||||
}
|
||||
|
||||
// MARK: - Multiple Content Types
|
||||
|
||||
@Test func multipleTypesAreRegistered() {
|
||||
let transferable = MultiTypeTransferable(text: "multi")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
#expect(item.types.contains(utf8Type))
|
||||
#expect(item.types.contains(plainType))
|
||||
}
|
||||
|
||||
@Test func multipleTypesProvideCorrectData() {
|
||||
let transferable = MultiTypeTransferable(text: "shared content")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Both types should provide the same content
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
if let utf8Data = pasteboard.data(forType: utf8Type) {
|
||||
#expect(String(data: utf8Data, encoding: .utf8) == "shared content")
|
||||
}
|
||||
|
||||
if let plainData = pasteboard.data(forType: plainType) {
|
||||
#expect(String(data: plainData, encoding: .utf8) == "shared content")
|
||||
}
|
||||
}
|
||||
}
|
||||
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Ghostty
|
||||
|
||||
struct TerminalSplitDropZoneTests {
|
||||
private let standardSize = CGSize(width: 100, height: 100)
|
||||
|
||||
// MARK: - Basic Edge Detection
|
||||
|
||||
@Test func topEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func bottomEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
@Test func leftEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func rightEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Corner Tie-Breaking
|
||||
// When distances are equal, the check order determines the result:
|
||||
// left -> right -> top -> bottom
|
||||
|
||||
@Test func topLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func topRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func bottomLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func bottomRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Center Point (All Distances Equal)
|
||||
|
||||
@Test func centerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
// MARK: - Non-Square Aspect Ratio
|
||||
|
||||
@Test func rectangularViewTopEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func rectangularViewLeftEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func tallRectangleTopEdge() {
|
||||
let size = CGSize(width: 100, height: 200)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
// MARK: - Out-of-Bounds Points
|
||||
|
||||
@Test func pointLeftOfViewSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func pointAboveViewSelectsTop() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func pointRightOfViewSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func pointBelowViewSelectsBottom() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Diagonal Regions (Triangular Zones)
|
||||
|
||||
@Test func upperLeftTriangleSelectsLeft() {
|
||||
// Point in the upper-left triangle, closer to left than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func upperRightTriangleSelectsRight() {
|
||||
// Point in the upper-right triangle, closer to right than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func lowerLeftTriangleSelectsLeft() {
|
||||
// Point in the lower-left triangle, closer to left than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func lowerRightTriangleSelectsRight() {
|
||||
// Point in the lower-right triangle, closer to right than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
wasmtime,
|
||||
wraptest,
|
||||
zig,
|
||||
zig_0_15,
|
||||
zip,
|
||||
llvmPackages_latest,
|
||||
bzip2,
|
||||
|
||||
@@ -20,16 +20,6 @@
|
||||
wayland-scanner,
|
||||
pkgs,
|
||||
}: let
|
||||
# The Zig hook has no way to select the release type without actual
|
||||
# overriding of the default flags.
|
||||
#
|
||||
# TODO: Once
|
||||
# https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is
|
||||
# ultimately acted on and has made its way to a nixpkgs implementation, this
|
||||
# can probably be removed in favor of that.
|
||||
zig_hook = zig_0_15.hook.overrideAttrs {
|
||||
zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off";
|
||||
};
|
||||
gi_typelib_path = import ./build-support/gi-typelib-path.nix {
|
||||
inherit pkgs lib stdenv;
|
||||
};
|
||||
@@ -73,7 +63,7 @@ in
|
||||
ncurses
|
||||
pandoc
|
||||
pkg-config
|
||||
zig_hook
|
||||
zig_0_15
|
||||
gobject-introspection
|
||||
wrapGAppsHook4
|
||||
blueprint-compiler
|
||||
@@ -92,12 +82,16 @@ in
|
||||
|
||||
GI_TYPELIB_PATH = gi_typelib_path;
|
||||
|
||||
dontSetZigDefaultFlags = true;
|
||||
|
||||
zigBuildFlags = [
|
||||
"--system"
|
||||
"${finalAttrs.deps}"
|
||||
"-Dversion-string=${finalAttrs.version}-${revision}-nix"
|
||||
"-Dgtk-x11=${lib.boolToString enableX11}"
|
||||
"-Dgtk-wayland=${lib.boolToString enableWayland}"
|
||||
"-Dcpu=baseline"
|
||||
"-Doptimize=${optimize}"
|
||||
"-Dstrip=${lib.boolToString strip}"
|
||||
];
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ in {
|
||||
client.succeed("${su "${ghostty} +new-window"}")
|
||||
client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
|
||||
|
||||
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.")
|
||||
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied."):
|
||||
client.sleep(2)
|
||||
client.send_chars("ssh ghostty@server\n")
|
||||
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
./common.nix
|
||||
];
|
||||
|
||||
services.xserver = {
|
||||
services = {
|
||||
displayManager = {
|
||||
gdm = {
|
||||
enable = true;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{...}: {
|
||||
imports = [
|
||||
./common-gnome.nix
|
||||
];
|
||||
|
||||
services.displayManager = {
|
||||
defaultSession = "gnome-xorg";
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
const std = @import("std");
|
||||
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const module = b.addModule("cimgui", .{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const imgui_ = b.lazyDependency("imgui", .{});
|
||||
const lib = b.addLibrary(.{
|
||||
.name = "cimgui",
|
||||
.root_module = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
.linkage = .static,
|
||||
});
|
||||
lib.linkLibC();
|
||||
lib.linkLibCpp();
|
||||
if (target.result.os.tag == .windows) {
|
||||
lib.linkSystemLibrary("imm32");
|
||||
}
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
|
||||
if (b.systemIntegrationOption("freetype", .{})) {
|
||||
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
|
||||
} else {
|
||||
const freetype = b.dependency("freetype", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
lib.linkLibrary(freetype.artifact("freetype"));
|
||||
|
||||
if (freetype.builder.lazyDependency(
|
||||
"freetype",
|
||||
.{},
|
||||
)) |freetype_dep| {
|
||||
module.addIncludePath(freetype_dep.path("include"));
|
||||
}
|
||||
}
|
||||
|
||||
if (imgui_) |imgui| lib.addIncludePath(imgui.path(""));
|
||||
module.addIncludePath(b.path("vendor"));
|
||||
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DCIMGUI_FREETYPE=1",
|
||||
"-DIMGUI_USE_WCHAR32=1",
|
||||
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
|
||||
});
|
||||
if (target.result.os.tag == .windows) {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
|
||||
});
|
||||
} else {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"",
|
||||
});
|
||||
}
|
||||
|
||||
if (imgui_) |imgui| {
|
||||
lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_draw.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_demo.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_widgets.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("imgui_tables.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{ .file = imgui.path("misc/freetype/imgui_freetype.cpp"), .flags = flags.items });
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_opengl3.cpp"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
if (target.result.os.tag.isDarwin()) {
|
||||
if (!target.query.isNative()) {
|
||||
try @import("apple_sdk").addPaths(b, lib);
|
||||
}
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_metal.mm"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
if (target.result.os.tag == .macos) {
|
||||
lib.addCSourceFile(.{
|
||||
.file = imgui.path("backends/imgui_impl_osx.mm"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
b.path("vendor"),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
|
||||
b.installArtifact(lib);
|
||||
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_exe.linkLibrary(lib);
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.{
|
||||
.name = .cimgui,
|
||||
.version = "1.90.6", // -docking branch
|
||||
.fingerprint = 0x49726f5f8acbc90d,
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// This should be kept in sync with the submodule in the cimgui source
|
||||
// code in ./vendor/ to be safe that they're compatible.
|
||||
.imgui = .{
|
||||
// ocornut/imgui
|
||||
.url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
.hash = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
.freetype = .{ .path = "../freetype" },
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub const c = @cImport({
|
||||
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
|
||||
@cInclude("cimgui.h");
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
pub const c = @import("c.zig").c;
|
||||
|
||||
// OpenGL
|
||||
pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.c) void;
|
||||
|
||||
// Metal
|
||||
pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.c) void;
|
||||
|
||||
// OSX
|
||||
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
|
||||
|
||||
test {}
|
||||
5943
pkg/cimgui/vendor/cimgui.cpp
vendored
5943
pkg/cimgui/vendor/cimgui.cpp
vendored
File diff suppressed because it is too large
Load Diff
6554
pkg/cimgui/vendor/cimgui.h
vendored
6554
pkg/cimgui/vendor/cimgui.h
vendored
File diff suppressed because it is too large
Load Diff
199
pkg/dcimgui/build.zig
Normal file
199
pkg/dcimgui/build.zig
Normal file
@@ -0,0 +1,199 @@
|
||||
const std = @import("std");
|
||||
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
const freetype = b.option(bool, "freetype", "Use Freetype") orelse false;
|
||||
const backend_opengl3 = b.option(bool, "backend-opengl3", "OpenGL3 backend") orelse false;
|
||||
const backend_metal = b.option(bool, "backend-metal", "Metal backend") orelse false;
|
||||
const backend_osx = b.option(bool, "backend-osx", "OSX backend") orelse false;
|
||||
|
||||
// Build options
|
||||
const options = b.addOptions();
|
||||
options.addOption(bool, "freetype", freetype);
|
||||
options.addOption(bool, "backend_opengl3", backend_opengl3);
|
||||
options.addOption(bool, "backend_metal", backend_metal);
|
||||
options.addOption(bool, "backend_osx", backend_osx);
|
||||
|
||||
// Main static lib
|
||||
const lib = b.addLibrary(.{
|
||||
.name = "dcimgui",
|
||||
.root_module = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
.linkage = .static,
|
||||
});
|
||||
lib.linkLibC();
|
||||
lib.linkLibCpp();
|
||||
b.installArtifact(lib);
|
||||
|
||||
// Zig module
|
||||
const mod = b.addModule("dcimgui", .{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
mod.addOptions("build_options", options);
|
||||
mod.linkLibrary(lib);
|
||||
|
||||
// We need to add proper Apple SDKs to find stdlib headers
|
||||
if (target.result.os.tag.isDarwin()) {
|
||||
if (!target.query.isNative()) {
|
||||
try @import("apple_sdk").addPaths(b, lib);
|
||||
}
|
||||
}
|
||||
|
||||
// Flags for C compilation, common to all.
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_USE_WCHAR32=1",
|
||||
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
|
||||
});
|
||||
if (freetype) try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_ENABLE_FREETYPE=1",
|
||||
});
|
||||
if (target.result.os.tag == .windows) {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
|
||||
});
|
||||
} else {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"",
|
||||
});
|
||||
}
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
// Add the core Dear Imgui source files
|
||||
if (b.lazyDependency("imgui", .{})) |upstream| {
|
||||
lib.addIncludePath(upstream.path(""));
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path(""),
|
||||
.files = &.{
|
||||
"imgui_demo.cpp",
|
||||
"imgui_draw.cpp",
|
||||
"imgui_tables.cpp",
|
||||
"imgui_widgets.cpp",
|
||||
"imgui.cpp",
|
||||
},
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path(""),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
|
||||
if (freetype) {
|
||||
lib.addCSourceFile(.{
|
||||
.file = upstream.path("misc/freetype/imgui_freetype.cpp"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
if (b.systemIntegrationOption("freetype", .{})) {
|
||||
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
|
||||
} else {
|
||||
const freetype_dep = b.dependency("freetype", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
lib.linkLibrary(freetype_dep.artifact("freetype"));
|
||||
if (freetype_dep.builder.lazyDependency(
|
||||
"freetype",
|
||||
.{},
|
||||
)) |freetype_upstream| {
|
||||
mod.addIncludePath(freetype_upstream.path("include"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (backend_metal) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_metal.mm"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_metal.h"} },
|
||||
);
|
||||
}
|
||||
if (backend_osx) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_osx.mm"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_osx.h"} },
|
||||
);
|
||||
}
|
||||
if (backend_opengl3) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_opengl3.cpp"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_opengl3.h"} },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the C bindings
|
||||
if (b.lazyDependency("bindings", .{})) |upstream| {
|
||||
lib.addIncludePath(upstream.path(""));
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path(""),
|
||||
.files = &.{
|
||||
"dcimgui.cpp",
|
||||
"dcimgui_internal.cpp",
|
||||
},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.addCSourceFiles(.{
|
||||
.root = b.path(""),
|
||||
.files = &.{"ext.cpp"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path(""),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
}
|
||||
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_exe.root_module.addOptions("build_options", options);
|
||||
test_exe.linkLibrary(lib);
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
26
pkg/dcimgui/build.zig.zon
Normal file
26
pkg/dcimgui/build.zig.zon
Normal file
@@ -0,0 +1,26 @@
|
||||
.{
|
||||
.name = .dcimgui,
|
||||
.version = "1.92.5", // -docking branch
|
||||
.fingerprint = 0x1a25797442c6324f,
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// The bindings and imgui versions below must match exactly.
|
||||
|
||||
.bindings = .{
|
||||
// https://github.com/dearimgui/dear_bindings
|
||||
.url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
.hash = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.imgui = .{
|
||||
// https://github.com/ocornut/imgui
|
||||
.url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
.hash = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
.freetype = .{ .path = "../freetype" },
|
||||
},
|
||||
}
|
||||
30
pkg/dcimgui/ext.cpp
Normal file
30
pkg/dcimgui/ext.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#include "imgui.h"
|
||||
|
||||
// This file contains custom extensions for functionality that isn't
|
||||
// properly supported by Dear Bindings yet. Namely:
|
||||
// https://github.com/dearimgui/dear_bindings/issues/55
|
||||
|
||||
// Wrap this in a namespace to keep it separate from the C++ API
|
||||
namespace cimgui
|
||||
{
|
||||
#include "dcimgui.h"
|
||||
}
|
||||
|
||||
extern "C"
|
||||
{
|
||||
CIMGUI_API void ImFontConfig_ImFontConfig(cimgui::ImFontConfig* self)
|
||||
{
|
||||
static_assert(sizeof(cimgui::ImFontConfig) == sizeof(::ImFontConfig), "ImFontConfig size mismatch");
|
||||
static_assert(alignof(cimgui::ImFontConfig) == alignof(::ImFontConfig), "ImFontConfig alignment mismatch");
|
||||
::ImFontConfig defaults;
|
||||
*reinterpret_cast<::ImFontConfig*>(self) = defaults;
|
||||
}
|
||||
|
||||
CIMGUI_API void ImGuiStyle_ImGuiStyle(cimgui::ImGuiStyle* self)
|
||||
{
|
||||
static_assert(sizeof(cimgui::ImGuiStyle) == sizeof(::ImGuiStyle), "ImGuiStyle size mismatch");
|
||||
static_assert(alignof(cimgui::ImGuiStyle) == alignof(::ImGuiStyle), "ImGuiStyle alignment mismatch");
|
||||
::ImGuiStyle defaults;
|
||||
*reinterpret_cast<::ImGuiStyle*>(self) = defaults;
|
||||
}
|
||||
}
|
||||
43
pkg/dcimgui/main.zig
Normal file
43
pkg/dcimgui/main.zig
Normal file
@@ -0,0 +1,43 @@
|
||||
pub const build_options = @import("build_options");
|
||||
|
||||
pub const c = @cImport({
|
||||
// This is set during the build so it also has to be set
|
||||
// during import time to get the right types. Without this
|
||||
// you get stack size mismatches on some structs.
|
||||
@cDefine("IMGUI_USE_WCHAR32", "1");
|
||||
@cInclude("dcimgui.h");
|
||||
});
|
||||
|
||||
// OpenGL3 backend
|
||||
pub extern fn ImGui_ImplOpenGL3_Init(glsl_version: ?[*:0]const u8) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(draw_data: *c.ImDrawData) callconv(.c) void;
|
||||
|
||||
// Metal backend
|
||||
pub extern fn ImGui_ImplMetal_Init(device: *anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_NewFrame(render_pass_descriptor: *anyopaque) callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_RenderDrawData(draw_data: *c.ImDrawData, command_buffer: *anyopaque, command_encoder: *anyopaque) callconv(.c) void;
|
||||
|
||||
// OSX
|
||||
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
|
||||
|
||||
// Internal API functions from dcimgui_internal.h
|
||||
// We declare these manually because the internal header contains bitfields
|
||||
// that Zig's cImport cannot translate.
|
||||
pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void;
|
||||
pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID;
|
||||
pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void;
|
||||
|
||||
// Extension functions from ext.cpp
|
||||
pub const ext = struct {
|
||||
pub extern fn ImFontConfig_ImFontConfig(self: *c.ImFontConfig) callconv(.c) void;
|
||||
pub extern fn ImGuiStyle_ImGuiStyle(self: *c.ImGuiStyle) callconv(.c) void;
|
||||
};
|
||||
|
||||
test {
|
||||
_ = c;
|
||||
}
|
||||
@@ -90,6 +90,10 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
|
||||
"-fno-sanitize=undefined",
|
||||
});
|
||||
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
const dynamic_link_opts = options.dynamic_link_opts;
|
||||
|
||||
// Zlib
|
||||
|
||||
@@ -66,6 +66,10 @@ fn buildGlslang(
|
||||
"-fno-sanitize-trap=undefined",
|
||||
});
|
||||
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
if (upstream_) |upstream| {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path(""),
|
||||
|
||||
@@ -72,6 +72,11 @@ pub fn build(b: *std.Build) !void {
|
||||
"-fno-sanitize=undefined",
|
||||
"-fno-sanitize-trap=undefined",
|
||||
});
|
||||
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
if (target.result.os.tag != .windows) {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-fmath-errno",
|
||||
|
||||
@@ -32,6 +32,10 @@ pub fn build(b: *std.Build) !void {
|
||||
"-fno-sanitize-trap=undefined",
|
||||
});
|
||||
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
lib.addCSourceFiles(.{
|
||||
.flags = flags.items,
|
||||
.files = &.{
|
||||
|
||||
@@ -74,6 +74,10 @@ fn buildSpirvCross(
|
||||
"-fno-sanitize-trap=undefined",
|
||||
});
|
||||
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
if (b.lazyDependency("spirv_cross", .{})) |upstream| {
|
||||
lib.addIncludePath(upstream.path(""));
|
||||
module.addIncludePath(upstream.path(""));
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
|
||||
# This file is distributed under the same license as the com.mitchellh.ghostty package.
|
||||
# Robin Pfäffle <r@rpfaeffle.com>, 2025.
|
||||
# Jan Klass <kissaki@posteo.de>, 2026.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: com.mitchellh.ghostty\n"
|
||||
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
|
||||
"POT-Creation-Date: 2025-12-19 10:30-0500\n"
|
||||
"PO-Revision-Date: 2025-08-25 19:38+0100\n"
|
||||
"Last-Translator: Robin <r@rpfaeffle.com>\n"
|
||||
"POT-Creation-Date: 2025-07-22 17:18+0000\n"
|
||||
"PO-Revision-Date: 2026-01-06 10:25+0100\n"
|
||||
"Last-Translator: Jan Klass <kissaki@posteo.de>\n"
|
||||
"Language-Team: German <translation-team-de@lists.sourceforge.net>\n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -18,235 +19,174 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201
|
||||
msgid "Authorize Clipboard Access"
|
||||
msgstr "Zugriff auf die Zwischenablage gewähren"
|
||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5
|
||||
msgid "Change Terminal Title"
|
||||
msgstr "Terminal-Titel bearbeiten"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17
|
||||
msgid "Deny"
|
||||
msgstr "Nicht erlauben"
|
||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
|
||||
msgid "Leave blank to restore the default title."
|
||||
msgstr "Leer lassen, um den Standardtitel wiederherzustellen."
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18
|
||||
msgid "Allow"
|
||||
msgstr "Erlauben"
|
||||
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92
|
||||
msgid "Remember choice for this split"
|
||||
msgstr "Auswahl für dieses geteilte Fenster beibehalten"
|
||||
|
||||
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93
|
||||
msgid "Reload configuration to show this prompt again"
|
||||
msgstr ""
|
||||
"Lade die Konfiguration erneut, um diese Eingabeaufforderung erneut anzuzeigen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7
|
||||
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:9
|
||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
|
||||
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
|
||||
#: src/apprt/gtk/CloseDialog.zig:44
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:85
|
||||
#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
|
||||
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5
|
||||
msgid "Configuration Errors"
|
||||
msgstr "Konfigurationsfehler"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7
|
||||
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
|
||||
msgid ""
|
||||
"One or more configuration errors were found. Please review the errors below, "
|
||||
"and either reload your configuration or ignore these errors."
|
||||
msgstr ""
|
||||
"Ein oder mehrere Konfigurationsfehler wurden gefunden. Bitte überprüfe die "
|
||||
"untenstehenden Fehler und lade entweder deine Konfiguration erneut oder "
|
||||
"Ein oder mehrere Konfigurationsfehler wurden gefunden. Bitte überprüfe "
|
||||
"die untenstehenden Fehler und lade entweder deine Konfiguration erneut oder "
|
||||
"ignoriere die Fehler."
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
|
||||
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9
|
||||
msgid "Ignore"
|
||||
msgstr "Ignorieren"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:317 src/apprt/gtk/ui/1.5/window.blp:293
|
||||
#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100
|
||||
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
|
||||
msgid "Reload Configuration"
|
||||
msgstr "Konfiguration neu laden"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/debug-warning.blp:7
|
||||
#: src/apprt/gtk/ui/1.3/debug-warning.blp:6
|
||||
msgid ""
|
||||
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
|
||||
msgstr ""
|
||||
"⚠️ Du verwendest einen Debug Build von Ghostty! Die Leistung wird reduziert "
|
||||
"sein."
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/inspector-window.blp:5
|
||||
msgid "Ghostty: Terminal Inspector"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:29
|
||||
msgid "Find…"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:64
|
||||
msgid "Previous Match"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/search-overlay.blp:74
|
||||
msgid "Next Match"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:6
|
||||
msgid "Oh, no."
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:7
|
||||
msgid "Unable to acquire an OpenGL context for rendering."
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:216 src/apprt/gtk/ui/1.5/window.blp:198
|
||||
msgid "Copy"
|
||||
msgstr "Kopieren"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:221 src/apprt/gtk/ui/1.5/window.blp:203
|
||||
msgid "Paste"
|
||||
msgstr "Einfügen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:226
|
||||
msgid "Notify on Next Command Finish"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:233 src/apprt/gtk/ui/1.5/window.blp:266
|
||||
msgid "Clear"
|
||||
msgstr "Leeren"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:238 src/apprt/gtk/ui/1.5/window.blp:271
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:245 src/apprt/gtk/ui/1.5/window.blp:235
|
||||
msgid "Split"
|
||||
msgstr "Fenster teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:248 src/apprt/gtk/ui/1.5/window.blp:238
|
||||
msgid "Change Title…"
|
||||
msgstr "Titel bearbeiten…"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:253 src/apprt/gtk/ui/1.5/window.blp:175
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:243
|
||||
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50
|
||||
msgid "Split Up"
|
||||
msgstr "Fenster nach oben teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:259 src/apprt/gtk/ui/1.5/window.blp:180
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:248
|
||||
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55
|
||||
msgid "Split Down"
|
||||
msgstr "Fenster nach unten teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:185
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:253
|
||||
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60
|
||||
msgid "Split Left"
|
||||
msgstr "Fenter nach links teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:271 src/apprt/gtk/ui/1.5/window.blp:190
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:258
|
||||
#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65
|
||||
msgid "Split Right"
|
||||
msgstr "Fenster nach rechts teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:278
|
||||
msgid "Tab"
|
||||
msgstr "Tab"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:281 src/apprt/gtk/ui/1.5/window.blp:57
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:222
|
||||
msgid "New Tab"
|
||||
msgstr "Neuer Tab"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:286 src/apprt/gtk/ui/1.5/window.blp:227
|
||||
msgid "Close Tab"
|
||||
msgstr "Tab schließen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:293
|
||||
msgid "Window"
|
||||
msgstr "Fenster"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:296 src/apprt/gtk/ui/1.5/window.blp:210
|
||||
msgid "New Window"
|
||||
msgstr "Neues Fenster"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:301 src/apprt/gtk/ui/1.5/window.blp:215
|
||||
msgid "Close Window"
|
||||
msgstr "Fenster schließen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:309
|
||||
msgid "Config"
|
||||
msgstr "Konfiguration"
|
||||
|
||||
#: src/apprt/gtk/ui/1.2/surface.blp:312 src/apprt/gtk/ui/1.5/window.blp:288
|
||||
msgid "Open Configuration"
|
||||
msgstr "Konfiguration öffnen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:5
|
||||
msgid "Change Terminal Title"
|
||||
msgstr "Terminal-Titel bearbeiten"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:6
|
||||
msgid "Leave blank to restore the default title."
|
||||
msgstr "Leer lassen, um den Standardtitel wiederherzustellen."
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:10
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108
|
||||
msgid "New Split"
|
||||
msgstr "Neues geteiltes Fenster"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126
|
||||
msgid "View Open Tabs"
|
||||
msgstr "Offene Tabs einblenden"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140
|
||||
msgid "Main Menu"
|
||||
msgstr "Hauptmenü"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:278
|
||||
msgid "Command Palette"
|
||||
msgstr "Befehlspalette"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:283
|
||||
msgid "Terminal Inspector"
|
||||
msgstr "Terminalinspektor"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:300 src/apprt/gtk/class/window.zig:1703
|
||||
msgid "About Ghostty"
|
||||
msgstr "Über Ghostty"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/window.blp:305
|
||||
msgid "Quit"
|
||||
msgstr "Beenden"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/command-palette.blp:17
|
||||
#: src/apprt/gtk/ui/1.5/command-palette.blp:16
|
||||
msgid "Execute a command…"
|
||||
msgstr "Einen Befehl ausführen…"
|
||||
|
||||
#: dist/linux/ghostty_nautilus.py:67
|
||||
msgid "Open in Ghostty"
|
||||
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
|
||||
msgid "Copy"
|
||||
msgstr "Kopieren"
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198
|
||||
msgid ""
|
||||
"An application is attempting to write to the clipboard. The current "
|
||||
"clipboard contents are shown below."
|
||||
msgstr ""
|
||||
"Eine Anwendung versucht in die Zwischenablage zu schreiben. Der aktuelle "
|
||||
"Inhalt der Zwischenablage wird unten angezeigt."
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11
|
||||
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11
|
||||
msgid "Paste"
|
||||
msgstr "Einfügen"
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73
|
||||
msgid "Clear"
|
||||
msgstr "Leeren"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42
|
||||
msgid "Split"
|
||||
msgstr "Fenster teilen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45
|
||||
msgid "Change Title…"
|
||||
msgstr "Titel bearbeiten…"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59
|
||||
msgid "Tab"
|
||||
msgstr "Tab"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30
|
||||
#: src/apprt/gtk/Window.zig:265
|
||||
msgid "New Tab"
|
||||
msgstr "Neuer Tab"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35
|
||||
msgid "Close Tab"
|
||||
msgstr "Tab schließen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73
|
||||
msgid "Window"
|
||||
msgstr "Fenster"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18
|
||||
msgid "New Window"
|
||||
msgstr "Neues Fenster"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23
|
||||
msgid "Close Window"
|
||||
msgstr "Fenster schließen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89
|
||||
msgid "Config"
|
||||
msgstr "Konfiguration"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95
|
||||
msgid "Open Configuration"
|
||||
msgstr "Konfiguration öffnen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85
|
||||
msgid "Command Palette"
|
||||
msgstr "Befehlspalette"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90
|
||||
msgid "Terminal Inspector"
|
||||
msgstr "Terminalinspektor"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107
|
||||
#: src/apprt/gtk/Window.zig:1038
|
||||
msgid "About Ghostty"
|
||||
msgstr "Über Ghostty"
|
||||
|
||||
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112
|
||||
msgid "Quit"
|
||||
msgstr "Beenden"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6
|
||||
msgid "Authorize Clipboard Access"
|
||||
msgstr "Zugriff auf die Zwischenablage gewähren"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7
|
||||
msgid ""
|
||||
"An application is attempting to read from the clipboard. The current "
|
||||
"clipboard contents are shown below."
|
||||
@@ -254,11 +194,45 @@ msgstr ""
|
||||
"Eine Anwendung versucht von der Zwischenablage zu lesen. Der aktuelle Inhalt "
|
||||
"der Zwischenablage wird unten angezeigt."
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10
|
||||
msgid "Deny"
|
||||
msgstr "Nicht erlauben"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11
|
||||
msgid "Allow"
|
||||
msgstr "Erlauben"
|
||||
|
||||
#: 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 "Auswahl für dieses geteilte Fenster beibehalten"
|
||||
|
||||
#: 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 ""
|
||||
"Lade die Konfiguration erneut, um diese Eingabeaufforderung erneut anzuzeigen"
|
||||
|
||||
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7
|
||||
#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7
|
||||
msgid ""
|
||||
"An application is attempting to write to the clipboard. The current "
|
||||
"clipboard contents are shown below."
|
||||
msgstr ""
|
||||
"Eine Anwendung versucht in die Zwischenablage zu schreiben. Der aktuelle "
|
||||
"Inhalt der Zwischenablage wird unten angezeigt."
|
||||
|
||||
#: 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"
|
||||
msgstr "Achtung: Möglicherweise unsicheres Einfügen"
|
||||
|
||||
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206
|
||||
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7
|
||||
msgid ""
|
||||
"Pasting this text into the terminal may be dangerous as it looks like some "
|
||||
"commands may be executed."
|
||||
@@ -266,70 +240,85 @@ msgstr ""
|
||||
"Diesen Text in das Terminal einzufügen könnte möglicherweise gefährlich "
|
||||
"sein. Es scheint, dass Anweisungen ausgeführt werden könnten."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:184
|
||||
#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: src/apprt/gtk/CloseDialog.zig:87
|
||||
msgid "Quit Ghostty?"
|
||||
msgstr "Ghostty schließen?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:185
|
||||
msgid "Close Tab?"
|
||||
msgstr "Tab schließen?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:186
|
||||
#: src/apprt/gtk/CloseDialog.zig:88
|
||||
msgid "Close Window?"
|
||||
msgstr "Fenster schließen?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:187
|
||||
#: src/apprt/gtk/CloseDialog.zig:89
|
||||
msgid "Close Tab?"
|
||||
msgstr "Tab schließen?"
|
||||
|
||||
#: src/apprt/gtk/CloseDialog.zig:90
|
||||
msgid "Close Split?"
|
||||
msgstr "Geteiltes Fenster schließen?"
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:193
|
||||
#: src/apprt/gtk/CloseDialog.zig:96
|
||||
msgid "All terminal sessions will be terminated."
|
||||
msgstr "Alle Terminalsitzungen werden beendet."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:194
|
||||
msgid "All terminal sessions in this tab will be terminated."
|
||||
msgstr "Alle Terminalsitzungen in diesem Tab werden beendet."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:195
|
||||
#: src/apprt/gtk/CloseDialog.zig:97
|
||||
msgid "All terminal sessions in this window will be terminated."
|
||||
msgstr "Alle Terminalsitzungen in diesem Fenster werden beendet."
|
||||
|
||||
#: src/apprt/gtk/class/close_confirmation_dialog.zig:196
|
||||
#: src/apprt/gtk/CloseDialog.zig:98
|
||||
msgid "All terminal sessions in this tab will be terminated."
|
||||
msgstr "Alle Terminalsitzungen in diesem Tab werden beendet."
|
||||
|
||||
#: src/apprt/gtk/CloseDialog.zig:99
|
||||
msgid "The currently running process in this split will be terminated."
|
||||
msgstr "Der aktuell laufende Prozess in diesem geteilten Fenster wird beendet."
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:959
|
||||
msgid "Command Finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:960
|
||||
msgid "Command Succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/class/surface.zig:961
|
||||
msgid "Command Failed"
|
||||
msgstr ""
|
||||
|
||||
#: src/apprt/gtk/class/surface_child_exited.zig:109
|
||||
msgid "Command succeeded"
|
||||
msgstr "Befehl erfolgreich"
|
||||
|
||||
#: src/apprt/gtk/class/surface_child_exited.zig:113
|
||||
msgid "Command failed"
|
||||
msgstr "Befehl fehlgeschlagen"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:990
|
||||
msgid "Reloaded the configuration"
|
||||
msgstr "Konfiguration wurde neu geladen"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1542
|
||||
#: src/apprt/gtk/Surface.zig:1266
|
||||
msgid "Copied to clipboard"
|
||||
msgstr "In die Zwischenablage kopiert"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1544
|
||||
#: src/apprt/gtk/Surface.zig:1268
|
||||
msgid "Cleared clipboard"
|
||||
msgstr "Zwischenablage geleert"
|
||||
|
||||
#: src/apprt/gtk/class/window.zig:1684
|
||||
#: src/apprt/gtk/Surface.zig:2525
|
||||
msgid "Command succeeded"
|
||||
msgstr "Befehl erfolgreich"
|
||||
|
||||
#: src/apprt/gtk/Surface.zig:2527
|
||||
msgid "Command failed"
|
||||
msgstr "Befehl fehlgeschlagen"
|
||||
|
||||
#: src/apprt/gtk/Window.zig:216
|
||||
msgid "Main Menu"
|
||||
msgstr "Hauptmenü"
|
||||
|
||||
#: src/apprt/gtk/Window.zig:239
|
||||
msgid "View Open Tabs"
|
||||
msgstr "Offene Tabs einblenden"
|
||||
|
||||
#: src/apprt/gtk/Window.zig:266
|
||||
msgid "New Split"
|
||||
msgstr "Neues geteiltes Fenster"
|
||||
|
||||
#: src/apprt/gtk/Window.zig:329
|
||||
msgid ""
|
||||
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
|
||||
msgstr ""
|
||||
"⚠️ Du verwendest einen Debug Build von Ghostty! Die Leistung wird reduziert "
|
||||
"sein."
|
||||
|
||||
#: src/apprt/gtk/Window.zig:775
|
||||
msgid "Reloaded the configuration"
|
||||
msgstr "Konfiguration wurde neu geladen"
|
||||
|
||||
#: src/apprt/gtk/Window.zig:1019
|
||||
msgid "Ghostty Developers"
|
||||
msgstr "Ghostty-Entwickler"
|
||||
|
||||
#: src/apprt/gtk/inspector.zig:144
|
||||
msgid "Ghostty: Terminal Inspector"
|
||||
msgstr "Ghostty: Terminalinspektor"
|
||||
|
||||
@@ -14,7 +14,14 @@ if [ -z "$XDG_DATA_HOME" ]; then
|
||||
export XDG_DATA_HOME="$SNAP_REAL_HOME/.local/share"
|
||||
fi
|
||||
|
||||
source "$SNAP_USER_DATA/.last_revision" 2>/dev/null || true
|
||||
if [ -f "$SNAP_USER_DATA/.last_revision" ]; then
|
||||
if ! source "$SNAP_USER_DATA/.last_revision" 2>/dev/null; then
|
||||
# file exist but sourcing it fails, so it's likely
|
||||
# not good anyway
|
||||
rm -f "$SNAP_USER_DATA/.last_revision"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$LAST_REVISION" = "$SNAP_REVISION" ]; then
|
||||
needs_update=false
|
||||
else
|
||||
|
||||
53
src/App.zig
53
src/App.zig
@@ -357,15 +357,17 @@ pub fn keyEvent(
|
||||
// Get the keybind entry for this event. We don't support key sequences
|
||||
// so we can look directly in the top-level set.
|
||||
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
|
||||
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
|
||||
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
// Sequences aren't supported. Our configuration parser verifies
|
||||
// this for global keybinds but we may still get an entry for
|
||||
// a non-global keybind.
|
||||
.leader => return false,
|
||||
|
||||
// Leaf entries are good
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
const actions: []const input.Binding.Action = leaf.actionsSlice();
|
||||
assert(actions.len > 0);
|
||||
|
||||
// If we aren't focused, then we only process global keybinds.
|
||||
if (!self.focused and !leaf.flags.global) return false;
|
||||
@@ -373,13 +375,7 @@ pub fn keyEvent(
|
||||
// Global keybinds are done using performAll so that they
|
||||
// can target all surfaces too.
|
||||
if (leaf.flags.global) {
|
||||
self.performAllAction(rt_app, leaf.action) catch |err| {
|
||||
log.warn("error performing global keybind action action={s} err={}", .{
|
||||
@tagName(leaf.action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
|
||||
self.performAllChainedAction(rt_app, actions);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -389,14 +385,20 @@ pub fn keyEvent(
|
||||
|
||||
// If we are focused, then we process keybinds only if they are
|
||||
// app-scoped. Otherwise, we do nothing. Surface-scoped should
|
||||
// be processed by Surface.keyEvent.
|
||||
const app_action = leaf.action.scoped(.app) orelse return false;
|
||||
self.performAction(rt_app, app_action) catch |err| {
|
||||
log.warn("error performing app keybind action action={s} err={}", .{
|
||||
@tagName(app_action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
// be processed by Surface.keyEvent. For chained actions, all
|
||||
// actions must be app-scoped.
|
||||
for (actions) |action| if (action.scoped(.app) == null) return false;
|
||||
for (actions) |action| {
|
||||
self.performAction(
|
||||
rt_app,
|
||||
action.scoped(.app).?,
|
||||
) catch |err| {
|
||||
log.warn("error performing app keybind action action={s} err={}", .{
|
||||
@tagName(action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -454,6 +456,23 @@ pub fn performAction(
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a chained action. We will continue executing each action
|
||||
/// even if there is a failure in a prior action.
|
||||
pub fn performAllChainedAction(
|
||||
self: *App,
|
||||
rt_app: *apprt.App,
|
||||
actions: []const input.Binding.Action,
|
||||
) void {
|
||||
for (actions) |action| {
|
||||
self.performAllAction(rt_app, action) catch |err| {
|
||||
log.warn("error performing chained action action={s} err={}", .{
|
||||
@tagName(action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an app-wide binding action. If the action is surface-specific
|
||||
/// then it will be performed on all surfaces. To perform only app-scoped
|
||||
/// actions, use performAction.
|
||||
|
||||
579
src/Surface.zig
579
src/Surface.zig
@@ -49,6 +49,10 @@ const Renderer = rendererpkg.Renderer;
|
||||
const min_window_width_cells: u32 = 10;
|
||||
const min_window_height_cells: u32 = 4;
|
||||
|
||||
/// The maximum number of key tables that can be active at any
|
||||
/// given time. `activate_key_table` calls after this are ignored.
|
||||
const max_active_key_tables = 8;
|
||||
|
||||
/// Allocator
|
||||
alloc: Allocator,
|
||||
|
||||
@@ -253,18 +257,9 @@ const Mouse = struct {
|
||||
|
||||
/// Keyboard state for the surface.
|
||||
pub const Keyboard = struct {
|
||||
/// The currently active keybindings for the surface. This is used to
|
||||
/// implement sequences: as leader keys are pressed, the active bindings
|
||||
/// set is updated to reflect the current leader key sequence. If this is
|
||||
/// null then the root bindings are used.
|
||||
bindings: ?*const input.Binding.Set = null,
|
||||
|
||||
/// The last handled binding. This is used to prevent encoding release
|
||||
/// events for handled bindings. We only need to keep track of one because
|
||||
/// at least at the time of writing this, its impossible for two keys of
|
||||
/// a combination to be handled by different bindings before the release
|
||||
/// of the prior (namely since you can't bind modifier-only).
|
||||
last_trigger: ?u64 = null,
|
||||
/// The currently active key sequence for the surface. If this is null
|
||||
/// then we're not currently in a key sequence.
|
||||
sequence_set: ?*const input.Binding.Set = null,
|
||||
|
||||
/// The queued keys when we're in the middle of a sequenced binding.
|
||||
/// These are flushed when the sequence is completed and unconsumed or
|
||||
@@ -272,7 +267,23 @@ pub const Keyboard = struct {
|
||||
///
|
||||
/// This is naturally bounded due to the configuration maximum
|
||||
/// length of a sequence.
|
||||
queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{},
|
||||
sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty,
|
||||
|
||||
/// The stack of tables that is currently active. The first value
|
||||
/// in this is the first activated table (NOT the default keybinding set).
|
||||
///
|
||||
/// This is bounded by `max_active_key_tables`.
|
||||
table_stack: std.ArrayListUnmanaged(struct {
|
||||
set: *const input.Binding.Set,
|
||||
once: bool,
|
||||
}) = .empty,
|
||||
|
||||
/// The last handled binding. This is used to prevent encoding release
|
||||
/// events for handled bindings. We only need to keep track of one because
|
||||
/// at least at the time of writing this, its impossible for two keys of
|
||||
/// a combination to be handled by different bindings before the release
|
||||
/// of the prior (namely since you can't bind modifier-only).
|
||||
last_trigger: ?u64 = null,
|
||||
};
|
||||
|
||||
/// The configuration that a surface has, this is copied from the main
|
||||
@@ -305,6 +316,7 @@ const DerivedConfig = struct {
|
||||
macos_option_as_alt: ?input.OptionAsAlt,
|
||||
selection_clear_on_copy: bool,
|
||||
selection_clear_on_typing: bool,
|
||||
selection_word_chars: []const u21,
|
||||
vt_kam_allowed: bool,
|
||||
wait_after_command: bool,
|
||||
window_padding_top: u32,
|
||||
@@ -316,12 +328,13 @@ const DerivedConfig = struct {
|
||||
window_width: u32,
|
||||
title: ?[:0]const u8,
|
||||
title_report: bool,
|
||||
links: []Link,
|
||||
links: []DerivedConfig.Link,
|
||||
link_previews: configpkg.LinkPreviews,
|
||||
scroll_to_bottom: configpkg.Config.ScrollToBottom,
|
||||
notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish,
|
||||
notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction,
|
||||
notify_on_command_finish_after: Duration,
|
||||
key_remaps: input.KeyRemapSet,
|
||||
|
||||
const Link = struct {
|
||||
regex: oni.Regex,
|
||||
@@ -336,7 +349,7 @@ const DerivedConfig = struct {
|
||||
|
||||
// Build all of our links
|
||||
const links = links: {
|
||||
var links: std.ArrayList(Link) = .empty;
|
||||
var links: std.ArrayList(DerivedConfig.Link) = .empty;
|
||||
defer links.deinit(alloc);
|
||||
for (config.link.links.items) |link| {
|
||||
var regex = try link.oniRegex();
|
||||
@@ -380,6 +393,7 @@ const DerivedConfig = struct {
|
||||
.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",
|
||||
.selection_word_chars = try alloc.dupe(u21, config.@"selection-word-chars".codepoints),
|
||||
.vt_kam_allowed = config.@"vt-kam-allowed",
|
||||
.wait_after_command = config.@"wait-after-command",
|
||||
.window_padding_top = config.@"window-padding-y".top_left,
|
||||
@@ -397,6 +411,7 @@ const DerivedConfig = struct {
|
||||
.notify_on_command_finish = config.@"notify-on-command-finish",
|
||||
.notify_on_command_finish_action = config.@"notify-on-command-finish-action",
|
||||
.notify_on_command_finish_after = config.@"notify-on-command-finish-after",
|
||||
.key_remaps = try config.@"key-remap".clone(alloc),
|
||||
|
||||
// Assignments happen sequentially so we have to do this last
|
||||
// so that the memory is captured from allocs above.
|
||||
@@ -793,8 +808,9 @@ pub fn deinit(self: *Surface) void {
|
||||
}
|
||||
|
||||
// Clean up our keyboard state
|
||||
for (self.keyboard.queued.items) |req| req.deinit();
|
||||
self.keyboard.queued.deinit(self.alloc);
|
||||
for (self.keyboard.sequence_queued.items) |req| req.deinit();
|
||||
self.keyboard.sequence_queued.deinit(self.alloc);
|
||||
self.keyboard.table_stack.deinit(self.alloc);
|
||||
|
||||
// Clean up our font grid
|
||||
self.app.font_grid_set.deref(self.font_grid_key);
|
||||
@@ -1014,7 +1030,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
|
||||
_ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard });
|
||||
},
|
||||
|
||||
.clipboard_write => |w| switch (w.req) {
|
||||
@@ -1059,8 +1075,6 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
||||
|
||||
.scrollbar => |scrollbar| self.updateScrollbar(scrollbar),
|
||||
|
||||
.report_color_scheme => |force| self.reportColorScheme(force),
|
||||
|
||||
.present_surface => try self.presentSurface(),
|
||||
|
||||
.password_input => |v| try self.passwordInput(v),
|
||||
@@ -1210,7 +1224,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void {
|
||||
break :gui false;
|
||||
}) return;
|
||||
|
||||
// If a native GUI notification was not showm. update our terminal to
|
||||
// If a native GUI notification was not shown, update our terminal to
|
||||
// note the abnormal exit.
|
||||
self.childExitedAbnormally(info) catch |err| {
|
||||
log.err("error handling abnormal child exit err={}", .{err});
|
||||
@@ -1220,7 +1234,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void {
|
||||
return;
|
||||
}
|
||||
|
||||
// We output a message so that the user knows whats going on and
|
||||
// We output a message so that the user knows what's going on and
|
||||
// doesn't think their terminal just froze. We show this unconditionally
|
||||
// on close even if `wait_after_command` is false and the surface closes
|
||||
// immediately because if a user does an `undo` to restore a closed
|
||||
@@ -1372,26 +1386,6 @@ fn passwordInput(self: *Surface, v: bool) !void {
|
||||
try self.queueRender();
|
||||
}
|
||||
|
||||
/// Sends a DSR response for the current color scheme to the pty. If
|
||||
/// force is false then we only send the response if the terminal mode
|
||||
/// 2031 is enabled.
|
||||
fn reportColorScheme(self: *Surface, force: bool) void {
|
||||
if (!force) {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
if (!self.renderer_state.terminal.modes.get(.report_color_scheme)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const output = switch (self.config_conditional_state.theme) {
|
||||
.light => "\x1B[?997;2n",
|
||||
.dark => "\x1B[?997;1n",
|
||||
};
|
||||
|
||||
self.queueIo(.{ .write_stable = output }, .unlocked);
|
||||
}
|
||||
|
||||
fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void {
|
||||
// IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE
|
||||
// to access anything other than values that never change on the surface.
|
||||
@@ -1587,10 +1581,10 @@ fn mouseRefreshLinks(
|
||||
}
|
||||
|
||||
const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false };
|
||||
switch (link[0]) {
|
||||
switch (link.action) {
|
||||
.open => {
|
||||
const str = try self.io.terminal.screens.active.selectionString(alloc, .{
|
||||
.sel = link[1],
|
||||
.sel = link.selection,
|
||||
.trim = false,
|
||||
});
|
||||
break :link .{
|
||||
@@ -1601,7 +1595,7 @@ fn mouseRefreshLinks(
|
||||
|
||||
._open_osc8 => {
|
||||
// Show the URL in the status bar
|
||||
const pin = link[1].start();
|
||||
const pin = link.selection.start();
|
||||
const uri = self.osc8URI(pin) orelse {
|
||||
log.warn("failed to get URI for OSC8 hyperlink", .{});
|
||||
break :link .{ null, false };
|
||||
@@ -1731,6 +1725,14 @@ pub fn updateConfig(
|
||||
// If we are in the middle of a key sequence, clear it.
|
||||
self.endKeySequence(.drop, .free);
|
||||
|
||||
// Deactivate all key tables since they may have changed. Importantly,
|
||||
// we store pointers into the config as part of our table stack so
|
||||
// we can't keep them active across config changes. But this behavior
|
||||
// also matches key sequences.
|
||||
_ = self.deactivateAllKeyTables() catch |err| {
|
||||
log.warn("failed to deactivate key tables err={}", .{err});
|
||||
};
|
||||
|
||||
// Before sending any other config changes, we give the renderer a new font
|
||||
// grid. We could check to see if there was an actual change to the font,
|
||||
// but this is easier and pretty rare so it's not a performance concern.
|
||||
@@ -2556,30 +2558,60 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
|
||||
/// then Ghosty will act as though the binding does not exist.
|
||||
pub fn keyEventIsBinding(
|
||||
self: *Surface,
|
||||
event: input.KeyEvent,
|
||||
) bool {
|
||||
event_orig: input.KeyEvent,
|
||||
) ?input.Binding.Flags {
|
||||
// Apply key remappings for consistency with keyCallback
|
||||
var event = event_orig;
|
||||
if (self.config.key_remaps.isRemapped(event_orig.mods)) {
|
||||
event.mods = self.config.key_remaps.apply(event_orig.mods);
|
||||
}
|
||||
|
||||
switch (event.action) {
|
||||
.release => return false,
|
||||
.release => return null,
|
||||
.press, .repeat => {},
|
||||
}
|
||||
|
||||
// Our keybinding set is either our current nested set (for
|
||||
// sequences) or the root set.
|
||||
const set = self.keyboard.bindings orelse &self.config.keybind.set;
|
||||
// Look up our entry
|
||||
const entry: input.Binding.Set.Entry = entry: {
|
||||
// If we're in a sequence, check the sequence set
|
||||
if (self.keyboard.sequence_set) |set| {
|
||||
break :entry set.getEvent(event) orelse return null;
|
||||
}
|
||||
|
||||
// log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null });
|
||||
// Check active key tables (inner-most to outer-most)
|
||||
const table_items = self.keyboard.table_stack.items;
|
||||
for (0..table_items.len) |i| {
|
||||
const rev_i: usize = table_items.len - 1 - i;
|
||||
if (table_items[rev_i].set.getEvent(event)) |entry| {
|
||||
break :entry entry;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a keybinding for this event then we return true.
|
||||
return set.getEvent(event) != null;
|
||||
// Check the root set
|
||||
break :entry self.config.keybind.set.getEvent(event) orelse return null;
|
||||
};
|
||||
|
||||
// Return flags based on the
|
||||
return switch (entry.value_ptr.*) {
|
||||
.leader => .{},
|
||||
inline .leaf, .leaf_chained => |v| v.flags,
|
||||
};
|
||||
}
|
||||
|
||||
/// Called for any key events. This handles keybindings, encoding and
|
||||
/// sending to the terminal, etc.
|
||||
pub fn keyCallback(
|
||||
self: *Surface,
|
||||
event: input.KeyEvent,
|
||||
event_orig: input.KeyEvent,
|
||||
) !InputEffect {
|
||||
// log.warn("text keyCallback event={}", .{event});
|
||||
// log.warn("text keyCallback event={}", .{event_orig});
|
||||
|
||||
// Apply key remappings to transform modifiers before any processing.
|
||||
// This allows users to remap modifier keys at the app level.
|
||||
var event = event_orig;
|
||||
if (self.config.key_remaps.isRemapped(event_orig.mods)) {
|
||||
event.mods = self.config.key_remaps.apply(event_orig.mods);
|
||||
}
|
||||
|
||||
// Crash metadata in case we crash in here
|
||||
crash.sentry.thread_state = self.crashThreadState();
|
||||
@@ -2617,7 +2649,6 @@ pub fn keyCallback(
|
||||
event,
|
||||
if (insp_ev) |*ev| ev else null,
|
||||
)) |v| return v;
|
||||
|
||||
// If we allow KAM and KAM is enabled then we do nothing.
|
||||
if (self.config.vt_kam_allowed) {
|
||||
self.renderer_state.mutex.lock();
|
||||
@@ -2791,38 +2822,70 @@ fn maybeHandleBinding(
|
||||
|
||||
// Find an entry in the keybind set that matches our event.
|
||||
const entry: input.Binding.Set.Entry = entry: {
|
||||
const set = self.keyboard.bindings orelse &self.config.keybind.set;
|
||||
// Handle key sequences first.
|
||||
if (self.keyboard.sequence_set) |set| {
|
||||
// Get our entry from the set for the given event.
|
||||
if (set.getEvent(event)) |v| break :entry v;
|
||||
|
||||
// Get our entry from the set for the given event.
|
||||
if (set.getEvent(event)) |v| break :entry v;
|
||||
// No entry found. We need to encode everything up to this
|
||||
// point and send to the pty since we're in a sequence.
|
||||
|
||||
// We ignore modifiers so that nested sequences such as
|
||||
// ctrl+a>ctrl+b>c work.
|
||||
if (event.key.modifier()) return null;
|
||||
|
||||
// If we have a catch-all of ignore, then we special case our
|
||||
// invalid sequence handling to ignore it.
|
||||
if (self.catchAllIsIgnore()) {
|
||||
self.endKeySequence(.drop, .retain);
|
||||
return .ignored;
|
||||
}
|
||||
|
||||
// No entry found. If we're not looking at the root set of the
|
||||
// bindings we need to encode everything up to this point and
|
||||
// send to the pty.
|
||||
//
|
||||
// We also ignore modifiers so that nested sequences such as
|
||||
// ctrl+a>ctrl+b>c work.
|
||||
if (self.keyboard.bindings != null and
|
||||
!event.key.modifier())
|
||||
{
|
||||
// Encode everything up to this point
|
||||
self.endKeySequence(.flush, .retain);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// No currently active sequence, move on to tables. For tables,
|
||||
// we search inner-most table to outer-most. The table stack does
|
||||
// NOT include the root set.
|
||||
const table_items = self.keyboard.table_stack.items;
|
||||
if (table_items.len > 0) {
|
||||
for (0..table_items.len) |i| {
|
||||
const rev_i: usize = table_items.len - 1 - i;
|
||||
const table = table_items[rev_i];
|
||||
if (table.set.getEvent(event)) |v| {
|
||||
// If this is a one-shot activation AND its the currently
|
||||
// active table, then we deactivate it after this.
|
||||
// Note: we may want to change the semantics here to
|
||||
// remove this table no matter where it is in the stack,
|
||||
// maybe.
|
||||
if (table.once and i == 0) _ = try self.performBindingAction(
|
||||
.deactivate_key_table,
|
||||
);
|
||||
|
||||
break :entry v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No table, use our default set
|
||||
break :entry self.config.keybind.set.getEvent(event) orelse
|
||||
return null;
|
||||
};
|
||||
|
||||
// Determine if this entry has an action or if its a leader key.
|
||||
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
|
||||
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
.leader => |set| {
|
||||
// Setup the next set we'll look at.
|
||||
self.keyboard.bindings = set;
|
||||
self.keyboard.sequence_set = set;
|
||||
|
||||
// Store this event so that we can drain and encode on invalid.
|
||||
// We don't need to cap this because it is naturally capped by
|
||||
// the config validation.
|
||||
if (try self.encodeKey(event, insp_ev)) |req| {
|
||||
try self.keyboard.queued.append(self.alloc, req);
|
||||
try self.keyboard.sequence_queued.append(self.alloc, req);
|
||||
}
|
||||
|
||||
// Start or continue our key sequence
|
||||
@@ -2840,9 +2903,8 @@ fn maybeHandleBinding(
|
||||
return .consumed;
|
||||
},
|
||||
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
const action = leaf.action;
|
||||
|
||||
// consumed determines if the input is consumed or if we continue
|
||||
// encoding the key (if we have a key to encode).
|
||||
@@ -2861,39 +2923,61 @@ fn maybeHandleBinding(
|
||||
// perform an action (below)
|
||||
self.keyboard.last_trigger = null;
|
||||
|
||||
// An action also always resets the binding set.
|
||||
self.keyboard.bindings = null;
|
||||
// An action also always resets the sequence set.
|
||||
self.keyboard.sequence_set = null;
|
||||
|
||||
// Setup our actions
|
||||
const actions = leaf.actionsSlice();
|
||||
|
||||
// Attempt to perform the action
|
||||
log.debug("key event binding flags={} action={f}", .{
|
||||
log.debug("key event binding flags={} action={any}", .{
|
||||
leaf.flags,
|
||||
action,
|
||||
actions,
|
||||
});
|
||||
const performed = performed: {
|
||||
// If this is a global or all action, then we perform it on
|
||||
// the app and it applies to every surface.
|
||||
if (leaf.flags.global or leaf.flags.all) {
|
||||
try self.app.performAllAction(self.rt_app, action);
|
||||
self.app.performAllChainedAction(
|
||||
self.rt_app,
|
||||
actions,
|
||||
);
|
||||
|
||||
// "All" actions are always performed since they are global.
|
||||
break :performed true;
|
||||
}
|
||||
|
||||
break :performed try self.performBindingAction(action);
|
||||
// Perform each action. We are performed if ANY of the chained
|
||||
// actions perform.
|
||||
var performed: bool = false;
|
||||
for (actions) |action| {
|
||||
if (self.performBindingAction(action)) |v| {
|
||||
performed = performed or v;
|
||||
} else |err| {
|
||||
log.info(
|
||||
"key binding action failed action={t} err={}",
|
||||
.{ action, err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
break :performed performed;
|
||||
};
|
||||
|
||||
if (performed) {
|
||||
// If we performed an action and it was a closing action,
|
||||
// our "self" pointer is not safe to use anymore so we need to
|
||||
// just exit immediately.
|
||||
if (closingAction(action)) {
|
||||
for (actions) |action| if (closingAction(action)) {
|
||||
log.debug("key binding is a closing binding, halting key event processing", .{});
|
||||
return .closed;
|
||||
}
|
||||
};
|
||||
|
||||
// If our action was "ignore" then we return the special input
|
||||
// effect of "ignored".
|
||||
if (action == .ignore) return .ignored;
|
||||
for (actions) |action| if (action == .ignore) {
|
||||
return .ignored;
|
||||
};
|
||||
}
|
||||
|
||||
// If we have the performable flag and the action was not performed,
|
||||
@@ -2917,7 +3001,18 @@ fn maybeHandleBinding(
|
||||
// Store our last trigger so we don't encode the release event
|
||||
self.keyboard.last_trigger = event.bindingHash();
|
||||
|
||||
if (insp_ev) |ev| ev.binding = action;
|
||||
if (insp_ev) |ev| {
|
||||
ev.binding = self.alloc.dupe(
|
||||
input.Binding.Action,
|
||||
actions,
|
||||
) catch |err| binding: {
|
||||
log.warn(
|
||||
"error allocating binding action for inspector err={}",
|
||||
.{err},
|
||||
);
|
||||
break :binding &.{};
|
||||
};
|
||||
}
|
||||
return .consumed;
|
||||
}
|
||||
|
||||
@@ -2928,6 +3023,58 @@ fn maybeHandleBinding(
|
||||
return null;
|
||||
}
|
||||
|
||||
fn deactivateAllKeyTables(self: *Surface) !bool {
|
||||
switch (self.keyboard.table_stack.items.len) {
|
||||
// No key table active. This does nothing.
|
||||
0 => return false,
|
||||
|
||||
// Clear the entire table stack.
|
||||
else => self.keyboard.table_stack.clearAndFree(self.alloc),
|
||||
}
|
||||
|
||||
// Notify the UI.
|
||||
_ = self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.key_table,
|
||||
.deactivate_all,
|
||||
) catch |err| {
|
||||
log.warn(
|
||||
"failed to notify app of key table err={}",
|
||||
.{err},
|
||||
);
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// This checks if the current keybinding sets have a catch_all binding
|
||||
/// with `ignore`. This is used to determine some special input cases.
|
||||
fn catchAllIsIgnore(self: *Surface) bool {
|
||||
// Get our catch all
|
||||
const entry: input.Binding.Set.Entry = entry: {
|
||||
const trigger: input.Binding.Trigger = .{ .key = .catch_all };
|
||||
|
||||
const table_items = self.keyboard.table_stack.items;
|
||||
for (0..table_items.len) |i| {
|
||||
const rev_i: usize = table_items.len - 1 - i;
|
||||
const entry = table_items[rev_i].set.get(trigger) orelse continue;
|
||||
break :entry entry;
|
||||
}
|
||||
|
||||
break :entry self.config.keybind.set.get(trigger) orelse
|
||||
return false;
|
||||
};
|
||||
|
||||
// We have a catch-all entry, see if its an ignore
|
||||
return switch (entry.value_ptr.*) {
|
||||
.leader => false,
|
||||
.leaf => |leaf| leaf.action == .ignore,
|
||||
.leaf_chained => |leaf| chained: for (leaf.actions.items) |action| {
|
||||
if (action == .ignore) break :chained true;
|
||||
} else false,
|
||||
};
|
||||
}
|
||||
|
||||
const KeySequenceQueued = enum { flush, drop };
|
||||
const KeySequenceMemory = enum { retain, free };
|
||||
|
||||
@@ -2952,27 +3099,30 @@ fn endKeySequence(
|
||||
);
|
||||
};
|
||||
|
||||
// No matter what we clear our current binding set. This restores
|
||||
// No matter what we clear our current sequence set. This restores
|
||||
// the set we look at to the root set.
|
||||
self.keyboard.bindings = null;
|
||||
self.keyboard.sequence_set = null;
|
||||
|
||||
if (self.keyboard.queued.items.len > 0) {
|
||||
switch (action) {
|
||||
.flush => for (self.keyboard.queued.items) |write_req| {
|
||||
self.queueIo(switch (write_req) {
|
||||
.small => |v| .{ .write_small = v },
|
||||
.stable => |v| .{ .write_stable = v },
|
||||
.alloc => |v| .{ .write_alloc = v },
|
||||
}, .unlocked);
|
||||
},
|
||||
// If we have no queued data, there is nothing else to do.
|
||||
if (self.keyboard.sequence_queued.items.len == 0) return;
|
||||
|
||||
.drop => for (self.keyboard.queued.items) |req| req.deinit(),
|
||||
}
|
||||
// Run the proper action first
|
||||
switch (action) {
|
||||
.flush => for (self.keyboard.sequence_queued.items) |write_req| {
|
||||
self.queueIo(switch (write_req) {
|
||||
.small => |v| .{ .write_small = v },
|
||||
.stable => |v| .{ .write_stable = v },
|
||||
.alloc => |v| .{ .write_alloc = v },
|
||||
}, .unlocked);
|
||||
},
|
||||
|
||||
switch (mem) {
|
||||
.free => self.keyboard.queued.clearAndFree(self.alloc),
|
||||
.retain => self.keyboard.queued.clearRetainingCapacity(),
|
||||
}
|
||||
.drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(),
|
||||
}
|
||||
|
||||
// Memory handling of the sequence after the action
|
||||
switch (mem) {
|
||||
.free => self.keyboard.sequence_queued.clearAndFree(self.alloc),
|
||||
.retain => self.keyboard.sequence_queued.clearRetainingCapacity(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3994,9 +4144,24 @@ pub fn mouseButtonCallback(
|
||||
}
|
||||
},
|
||||
|
||||
// Double click, select the word under our mouse
|
||||
// Double click, select the word under our mouse.
|
||||
// First try to detect if we're clicking on a URL to select the entire URL.
|
||||
2 => {
|
||||
const sel_ = self.io.terminal.screens.active.selectWord(pin.*);
|
||||
const sel_ = sel: {
|
||||
// Try link detection without requiring modifier keys
|
||||
if (self.linkAtPin(
|
||||
pin.*,
|
||||
null,
|
||||
)) |result_| {
|
||||
if (result_) |result| {
|
||||
break :sel result.selection;
|
||||
}
|
||||
} else |_| {
|
||||
// Ignore any errors, likely regex errors.
|
||||
}
|
||||
|
||||
break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars);
|
||||
};
|
||||
if (sel_) |sel| {
|
||||
try self.io.terminal.screens.active.select(sel);
|
||||
try self.queueRender();
|
||||
@@ -4026,7 +4191,7 @@ pub fn mouseButtonCallback(
|
||||
.selection
|
||||
else
|
||||
.standard;
|
||||
try self.startClipboardRequest(clipboard, .{ .paste = {} });
|
||||
_ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
|
||||
}
|
||||
|
||||
// Right-click down selects word for context menus. If the apprt
|
||||
@@ -4040,8 +4205,8 @@ pub fn mouseButtonCallback(
|
||||
|
||||
// Get our viewport pin
|
||||
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
const pin = pin: {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
const pt_viewport = self.posToViewport(pos.x, pos.y);
|
||||
const pin = screen.pages.pin(.{
|
||||
.viewport = .{
|
||||
@@ -4072,8 +4237,17 @@ pub fn mouseButtonCallback(
|
||||
// word selection where we clicked.
|
||||
}
|
||||
|
||||
const sel = screen.selectWord(pin) orelse break :sel;
|
||||
try self.setSelection(sel);
|
||||
// If there is a link at this position, we want to
|
||||
// select the link. Otherwise, select the word.
|
||||
if (try self.linkAtPos(pos)) |link| {
|
||||
try self.setSelection(link.selection);
|
||||
} else {
|
||||
const sel = screen.selectWord(
|
||||
pin,
|
||||
self.config.selection_word_chars,
|
||||
) orelse break :sel;
|
||||
try self.setSelection(sel);
|
||||
}
|
||||
try self.queueRender();
|
||||
|
||||
// Don't consume so that we show the context menu in apprt.
|
||||
@@ -4104,7 +4278,7 @@ pub fn mouseButtonCallback(
|
||||
// request so we need to unlock.
|
||||
self.renderer_state.mutex.unlock();
|
||||
defer self.renderer_state.mutex.lock();
|
||||
try self.startClipboardRequest(.standard, .paste);
|
||||
_ = try self.startClipboardRequest(.standard, .paste);
|
||||
|
||||
// We don't need to clear selection because we didn't have
|
||||
// one to begin with.
|
||||
@@ -4119,7 +4293,7 @@ pub fn mouseButtonCallback(
|
||||
// request so we need to unlock.
|
||||
self.renderer_state.mutex.unlock();
|
||||
defer self.renderer_state.mutex.lock();
|
||||
try self.startClipboardRequest(.standard, .paste);
|
||||
_ = try self.startClipboardRequest(.standard, .paste);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4184,16 +4358,18 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
|
||||
}
|
||||
}
|
||||
|
||||
const Link = struct {
|
||||
action: input.Link.Action,
|
||||
selection: terminal.Selection,
|
||||
};
|
||||
|
||||
/// Returns the link at the given cursor position, if any.
|
||||
///
|
||||
/// Requires the renderer mutex is held.
|
||||
fn linkAtPos(
|
||||
self: *Surface,
|
||||
pos: apprt.CursorPos,
|
||||
) !?struct {
|
||||
input.Link.Action,
|
||||
terminal.Selection,
|
||||
} {
|
||||
) !?Link {
|
||||
// Convert our cursor position to a screen point.
|
||||
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
|
||||
const mouse_pin: terminal.Pin = mouse_pin: {
|
||||
@@ -4214,14 +4390,27 @@ fn linkAtPos(
|
||||
const cell = rac.cell;
|
||||
if (!cell.hyperlink) break :hyperlink;
|
||||
const sel = terminal.Selection.init(mouse_pin, mouse_pin, false);
|
||||
return .{ ._open_osc8, sel };
|
||||
return .{ .action = ._open_osc8, .selection = sel };
|
||||
}
|
||||
|
||||
// If we have no OSC8 links then we fallback to regex-based URL detection.
|
||||
// If we have no configured links we can save a lot of work going forward.
|
||||
// Fall back to configured links
|
||||
return try self.linkAtPin(mouse_pin, mouse_mods);
|
||||
}
|
||||
|
||||
/// Detects if a link is present at the given pin.
|
||||
///
|
||||
/// If mouse mods is null then mouse mod requirements are ignored (all
|
||||
/// configured links are checked).
|
||||
///
|
||||
/// Requires the renderer state mutex is held.
|
||||
fn linkAtPin(
|
||||
self: *Surface,
|
||||
mouse_pin: terminal.Pin,
|
||||
mouse_mods: ?input.Mods,
|
||||
) !?Link {
|
||||
if (self.config.links.len == 0) return null;
|
||||
|
||||
// Get the line we're hovering over.
|
||||
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
|
||||
const line = screen.selectLine(.{
|
||||
.pin = mouse_pin,
|
||||
.whitespace = null,
|
||||
@@ -4236,12 +4425,12 @@ fn linkAtPos(
|
||||
}));
|
||||
defer strmap.deinit(self.alloc);
|
||||
|
||||
// Go through each link and see if we clicked it
|
||||
for (self.config.links) |link| {
|
||||
switch (link.highlight) {
|
||||
// Skip highlight/mods check when mouse_mods is null (double-click mode)
|
||||
if (mouse_mods) |mods| switch (link.highlight) {
|
||||
.always, .hover => {},
|
||||
.always_mods, .hover_mods => |v| if (!v.equal(mouse_mods)) continue,
|
||||
}
|
||||
.always_mods, .hover_mods => |v| if (!v.equal(mods)) continue,
|
||||
};
|
||||
|
||||
var it = strmap.searchIterator(link.regex);
|
||||
while (true) {
|
||||
@@ -4249,7 +4438,10 @@ fn linkAtPos(
|
||||
defer match.deinit();
|
||||
const sel = match.selection();
|
||||
if (!sel.contains(screen, mouse_pin)) continue;
|
||||
return .{ link.action, sel };
|
||||
return .{
|
||||
.action = link.action,
|
||||
.selection = sel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4280,11 +4472,11 @@ fn mouseModsWithCapture(self: *Surface, mods: input.Mods) input.Mods {
|
||||
///
|
||||
/// Requires the renderer state mutex is held.
|
||||
fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
|
||||
const action, const sel = try self.linkAtPos(pos) orelse return false;
|
||||
switch (action) {
|
||||
const link = try self.linkAtPos(pos) orelse return false;
|
||||
switch (link.action) {
|
||||
.open => {
|
||||
const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{
|
||||
.sel = sel,
|
||||
.sel = link.selection,
|
||||
.trim = false,
|
||||
});
|
||||
defer self.alloc.free(str);
|
||||
@@ -4297,7 +4489,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
|
||||
},
|
||||
|
||||
._open_osc8 => {
|
||||
const uri = self.osc8URI(sel.start()) orelse {
|
||||
const uri = self.osc8URI(link.selection.start()) orelse {
|
||||
log.warn("failed to get URI for OSC8 hyperlink", .{});
|
||||
return false;
|
||||
};
|
||||
@@ -4374,7 +4566,10 @@ pub fn mousePressureCallback(
|
||||
// This should always be set in this state but we don't want
|
||||
// to handle state inconsistency here.
|
||||
const pin = self.mouse.left_click_pin orelse break :select;
|
||||
const sel = self.io.terminal.screens.active.selectWord(pin.*) orelse break :select;
|
||||
const sel = self.io.terminal.screens.active.selectWord(
|
||||
pin.*,
|
||||
self.config.selection_word_chars,
|
||||
) orelse break :select;
|
||||
try self.io.terminal.screens.active.select(sel);
|
||||
try self.queueRender();
|
||||
}
|
||||
@@ -4597,7 +4792,11 @@ fn dragLeftClickDouble(
|
||||
const click_pin = self.mouse.left_click_pin.?.*;
|
||||
|
||||
// Get the word closest to our starting click.
|
||||
const word_start = screen.selectWordBetween(click_pin, drag_pin) orelse {
|
||||
const word_start = screen.selectWordBetween(
|
||||
click_pin,
|
||||
drag_pin,
|
||||
self.config.selection_word_chars,
|
||||
) orelse {
|
||||
try self.setSelection(null);
|
||||
return;
|
||||
};
|
||||
@@ -4606,6 +4805,7 @@ fn dragLeftClickDouble(
|
||||
const word_current = screen.selectWordBetween(
|
||||
drag_pin,
|
||||
click_pin,
|
||||
self.config.selection_word_chars,
|
||||
) orelse {
|
||||
try self.setSelection(null);
|
||||
return;
|
||||
@@ -4836,7 +5036,7 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {
|
||||
self.notifyConfigConditionalState();
|
||||
|
||||
// If mode 2031 is on, then we report the change live.
|
||||
self.reportColorScheme(false);
|
||||
self.queueIo(.{ .color_scheme_report = .{ .force = false } }, .unlocked);
|
||||
}
|
||||
|
||||
pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate {
|
||||
@@ -5016,6 +5216,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
);
|
||||
},
|
||||
|
||||
.search_selection => {
|
||||
const selection = try self.selectionString(self.alloc) orelse return false;
|
||||
defer self.alloc.free(selection);
|
||||
return try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.start_search,
|
||||
.{ .needle = selection },
|
||||
);
|
||||
},
|
||||
|
||||
.end_search => {
|
||||
// We only return that this was performed if we actually
|
||||
// stopped a search, but we also send the apprt end_search so
|
||||
@@ -5131,11 +5341,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
if (try self.linkAtPos(pos)) |link_info| {
|
||||
const url_text = switch (link_info[0]) {
|
||||
const url_text = switch (link_info.action) {
|
||||
.open => url_text: {
|
||||
// For regex links, get the text from selection
|
||||
break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{
|
||||
.sel = link_info[1],
|
||||
.sel = link_info.selection,
|
||||
.trim = self.config.clipboard_trim_trailing_spaces,
|
||||
})) catch |err| {
|
||||
log.err("error reading url string err={}", .{err});
|
||||
@@ -5145,7 +5355,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
|
||||
._open_osc8 => url_text: {
|
||||
// For OSC8 links, get the URI directly from hyperlink data
|
||||
const uri = self.osc8URI(link_info[1].start()) orelse {
|
||||
const uri = self.osc8URI(link_info.selection.start()) orelse {
|
||||
log.warn("failed to get URI for OSC8 hyperlink", .{});
|
||||
return false;
|
||||
};
|
||||
@@ -5183,12 +5393,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
return true;
|
||||
},
|
||||
|
||||
.paste_from_clipboard => try self.startClipboardRequest(
|
||||
.paste_from_clipboard => return try self.startClipboardRequest(
|
||||
.standard,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
|
||||
.paste_from_selection => try self.startClipboardRequest(
|
||||
.paste_from_selection => return try self.startClipboardRequest(
|
||||
.selection,
|
||||
.{ .paste = {} },
|
||||
),
|
||||
@@ -5566,6 +5776,95 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
{},
|
||||
),
|
||||
|
||||
inline .activate_key_table,
|
||||
.activate_key_table_once,
|
||||
=> |name, tag| {
|
||||
// Look up the table in our config
|
||||
const set = self.config.keybind.tables.getPtr(name) orelse {
|
||||
log.debug("key table not found: {s}", .{name});
|
||||
return false;
|
||||
};
|
||||
|
||||
// If this is the same table as is currently active, then
|
||||
// do nothing.
|
||||
if (self.keyboard.table_stack.items.len > 0) {
|
||||
const items = self.keyboard.table_stack.items;
|
||||
const active = items[items.len - 1].set;
|
||||
if (active == set) {
|
||||
log.debug("ignoring duplicate activate table: {s}", .{name});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're already at the max, ignore it.
|
||||
if (self.keyboard.table_stack.items.len >= max_active_key_tables) {
|
||||
log.info(
|
||||
"ignoring activate table, max depth reached: {s}",
|
||||
.{name},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add the table to the stack.
|
||||
try self.keyboard.table_stack.append(self.alloc, .{
|
||||
.set = set,
|
||||
.once = tag == .activate_key_table_once,
|
||||
});
|
||||
|
||||
// Notify the UI.
|
||||
_ = self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.key_table,
|
||||
.{ .activate = name },
|
||||
) catch |err| {
|
||||
log.warn(
|
||||
"failed to notify app of key table err={}",
|
||||
.{err},
|
||||
);
|
||||
};
|
||||
|
||||
log.debug("key table activated: {s}", .{name});
|
||||
},
|
||||
|
||||
.deactivate_key_table => {
|
||||
switch (self.keyboard.table_stack.items.len) {
|
||||
// No key table active. This does nothing.
|
||||
0 => return false,
|
||||
|
||||
// Final key table active, clear our state.
|
||||
1 => self.keyboard.table_stack.clearAndFree(self.alloc),
|
||||
|
||||
// Restore the prior key table. We don't free any memory in
|
||||
// this case because we assume it will be freed later when
|
||||
// we finish our key table.
|
||||
else => _ = self.keyboard.table_stack.pop(),
|
||||
}
|
||||
|
||||
// Notify the UI.
|
||||
_ = self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.key_table,
|
||||
.deactivate,
|
||||
) catch |err| {
|
||||
log.warn(
|
||||
"failed to notify app of key table err={}",
|
||||
.{err},
|
||||
);
|
||||
};
|
||||
},
|
||||
|
||||
.deactivate_all_key_tables => {
|
||||
return try self.deactivateAllKeyTables();
|
||||
},
|
||||
|
||||
.end_key_sequence => {
|
||||
// End the key sequence and flush queued keys to the terminal,
|
||||
// but don't encode the key that triggered this action. This
|
||||
// will do that because leaf keys (keys with bindings) aren't
|
||||
// in the queued encoding list.
|
||||
self.endKeySequence(.flush, .retain);
|
||||
},
|
||||
|
||||
.crash => |location| switch (location) {
|
||||
.main => @panic("crash binding action, crashing intentionally"),
|
||||
|
||||
@@ -5821,11 +6120,15 @@ pub fn completeClipboardRequest(
|
||||
|
||||
/// This starts a clipboard request, with some basic validation. For example,
|
||||
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
|
||||
///
|
||||
/// Returns true if the request was started, false if it was not (e.g., clipboard
|
||||
/// doesn't contain text for paste requests). This allows performable keybinds
|
||||
/// to pass through when the action cannot be performed.
|
||||
fn startClipboardRequest(
|
||||
self: *Surface,
|
||||
loc: apprt.Clipboard,
|
||||
req: apprt.ClipboardRequest,
|
||||
) !void {
|
||||
) !bool {
|
||||
switch (req) {
|
||||
.paste => {}, // always allowed
|
||||
.osc_52_read => if (self.config.clipboard_read == .deny) {
|
||||
@@ -5833,14 +6136,14 @@ fn startClipboardRequest(
|
||||
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
|
||||
.{},
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
},
|
||||
|
||||
// No clipboard write code paths travel through this function
|
||||
.osc_52_write => unreachable,
|
||||
}
|
||||
|
||||
try self.rt_surface.clipboardRequest(loc, req);
|
||||
return try self.rt_surface.clipboardRequest(loc, req);
|
||||
}
|
||||
|
||||
fn completeClipboardPaste(
|
||||
|
||||
@@ -250,6 +250,9 @@ pub const Action = union(Key) {
|
||||
/// key mode because other input may be ignored.
|
||||
key_sequence: KeySequence,
|
||||
|
||||
/// A key table has been activated or deactivated.
|
||||
key_table: KeyTable,
|
||||
|
||||
/// A terminal color was changed programmatically through things
|
||||
/// such as OSC 10/11.
|
||||
color_change: ColorChange,
|
||||
@@ -310,7 +313,9 @@ pub const Action = union(Key) {
|
||||
/// A command has finished,
|
||||
command_finished: CommandFinished,
|
||||
|
||||
/// Start the search overlay with an optional initial needle.
|
||||
/// Start the search overlay with an optional initial needle. If the
|
||||
/// search is already active and the needle is non-empty, update the
|
||||
/// current search needle and focus the search input.
|
||||
start_search: StartSearch,
|
||||
|
||||
/// End the search overlay, clearing the search state and hiding it.
|
||||
@@ -371,6 +376,7 @@ pub const Action = union(Key) {
|
||||
float_window,
|
||||
secure_input,
|
||||
key_sequence,
|
||||
key_table,
|
||||
color_change,
|
||||
reload_config,
|
||||
config_change,
|
||||
@@ -711,6 +717,50 @@ pub const KeySequence = union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
pub const KeyTable = union(enum) {
|
||||
activate: []const u8,
|
||||
deactivate,
|
||||
deactivate_all,
|
||||
|
||||
// Sync with: ghostty_action_key_table_tag_e
|
||||
pub const Tag = enum(c_int) {
|
||||
activate,
|
||||
deactivate,
|
||||
deactivate_all,
|
||||
};
|
||||
|
||||
// Sync with: ghostty_action_key_table_u
|
||||
pub const CValue = extern union {
|
||||
activate: extern struct {
|
||||
name: [*]const u8,
|
||||
len: usize,
|
||||
},
|
||||
};
|
||||
|
||||
// Sync with: ghostty_action_key_table_s
|
||||
pub const C = extern struct {
|
||||
tag: Tag,
|
||||
value: CValue,
|
||||
};
|
||||
|
||||
pub fn cval(self: KeyTable) C {
|
||||
return switch (self) {
|
||||
.activate => |name| .{
|
||||
.tag = .activate,
|
||||
.value = .{ .activate = .{ .name = name.ptr, .len = name.len } },
|
||||
},
|
||||
.deactivate => .{
|
||||
.tag = .deactivate,
|
||||
.value = undefined,
|
||||
},
|
||||
.deactivate_all => .{
|
||||
.tag = .deactivate_all,
|
||||
.value = undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const ColorChange = extern struct {
|
||||
kind: ColorKind,
|
||||
r: u8,
|
||||
|
||||
@@ -155,7 +155,7 @@ pub const App = struct {
|
||||
while (it.next()) |entry| {
|
||||
switch (entry.value_ptr.*) {
|
||||
.leader => {},
|
||||
.leaf => |leaf| if (leaf.flags.global) return true,
|
||||
inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,9 @@ pub const Surface = struct {
|
||||
|
||||
/// Wait after the command exits
|
||||
wait_after_command: bool = false,
|
||||
|
||||
/// Context for the new surface
|
||||
context: apprt.surface.NewSurfaceContext = .window,
|
||||
};
|
||||
|
||||
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
||||
@@ -477,7 +480,7 @@ pub const Surface = struct {
|
||||
errdefer app.core_app.deleteSurface(self);
|
||||
|
||||
// Shallow copy the config so that we can modify it.
|
||||
var config = try apprt.surface.newConfig(app.core_app, &app.config);
|
||||
var config = try apprt.surface.newConfig(app.core_app, &app.config, opts.context);
|
||||
defer config.deinit();
|
||||
|
||||
// If we have a working directory from the options then we set it.
|
||||
@@ -539,13 +542,20 @@ pub const Surface = struct {
|
||||
// If we have an initial input then we set it.
|
||||
if (opts.initial_input) |c_input| {
|
||||
const alloc = config.arenaAlloc();
|
||||
|
||||
// We need to escape the string because the "raw" field
|
||||
// expects a Zig string.
|
||||
var buf: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer buf.deinit();
|
||||
try std.zig.stringEscape(
|
||||
std.mem.sliceTo(c_input, 0),
|
||||
&buf.writer,
|
||||
);
|
||||
|
||||
config.input.list.clearRetainingCapacity();
|
||||
try config.input.list.append(
|
||||
alloc,
|
||||
.{ .raw = try alloc.dupeZ(u8, std.mem.sliceTo(
|
||||
c_input,
|
||||
0,
|
||||
)) },
|
||||
.{ .raw = try buf.toOwnedSliceSentinel(0) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -652,7 +662,7 @@ pub const Surface = struct {
|
||||
self: *Surface,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) !void {
|
||||
) !bool {
|
||||
// We need to allocate to get a pointer to store our clipboard request
|
||||
// so that it is stable until the read_clipboard callback and call
|
||||
// complete_clipboard_request. This sucks but clipboard requests aren't
|
||||
@@ -667,6 +677,10 @@ pub const Surface = struct {
|
||||
@intCast(@intFromEnum(clipboard_type)),
|
||||
state_ptr,
|
||||
);
|
||||
|
||||
// Embedded apprt can't synchronously check clipboard content types,
|
||||
// so we always return true to indicate the request was started.
|
||||
return true;
|
||||
}
|
||||
|
||||
fn completeClipboardRequest(
|
||||
@@ -890,14 +904,23 @@ pub const Surface = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options {
|
||||
pub fn newSurfaceOptions(self: *const Surface, context: apprt.surface.NewSurfaceContext) apprt.Surface.Options {
|
||||
const font_size: f32 = font_size: {
|
||||
if (!self.app.config.@"window-inherit-font-size") break :font_size 0;
|
||||
break :font_size self.core_surface.font_size.points;
|
||||
};
|
||||
|
||||
const working_directory: ?[*:0]const u8 = wd: {
|
||||
if (!apprt.surface.shouldInheritWorkingDirectory(context, &self.app.config)) break :wd null;
|
||||
const cwd = self.core_surface.pwd(self.app.core_app.alloc) catch null orelse break :wd null;
|
||||
defer self.app.core_app.alloc.free(cwd);
|
||||
break :wd self.app.core_app.alloc.dupeZ(u8, cwd) catch null;
|
||||
};
|
||||
|
||||
return .{
|
||||
.font_size = font_size,
|
||||
.working_directory = working_directory,
|
||||
.context = context,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -943,7 +966,7 @@ pub const Surface = struct {
|
||||
/// Inspector is the state required for the terminal inspector. A terminal
|
||||
/// inspector is 1:1 with a Surface.
|
||||
pub const Inspector = struct {
|
||||
const cimgui = @import("cimgui");
|
||||
const cimgui = @import("dcimgui");
|
||||
|
||||
surface: *Surface,
|
||||
ig_ctx: *cimgui.c.ImGuiContext,
|
||||
@@ -964,10 +987,10 @@ pub const Inspector = struct {
|
||||
};
|
||||
|
||||
pub fn init(surface: *Surface) !Inspector {
|
||||
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();
|
||||
const ig_ctx = cimgui.c.ImGui_CreateContext(null) orelse return error.OutOfMemory;
|
||||
errdefer cimgui.c.ImGui_DestroyContext(ig_ctx);
|
||||
cimgui.c.ImGui_SetCurrentContext(ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
io.BackendPlatformName = "ghostty_embedded";
|
||||
|
||||
// Setup our core inspector
|
||||
@@ -984,9 +1007,9 @@ pub const Inspector = struct {
|
||||
|
||||
pub fn deinit(self: *Inspector) void {
|
||||
self.surface.core_surface.deactivateInspector();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
if (self.backend) |v| v.deinit();
|
||||
cimgui.c.igDestroyContext(self.ig_ctx);
|
||||
cimgui.c.ImGui_DestroyContext(self.ig_ctx);
|
||||
}
|
||||
|
||||
/// Queue a render for the next frame.
|
||||
@@ -997,7 +1020,7 @@ pub const Inspector = struct {
|
||||
/// Initialize the inspector for a metal backend.
|
||||
pub fn initMetal(self: *Inspector, device: objc.Object) bool {
|
||||
defer device.msgSend(void, objc.sel("release"), .{});
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
|
||||
if (self.backend) |v| {
|
||||
v.deinit();
|
||||
@@ -1032,7 +1055,7 @@ pub const Inspector = struct {
|
||||
for (0..2) |_| {
|
||||
cimgui.ImGui_ImplMetal_NewFrame(desc.value);
|
||||
try self.newFrame();
|
||||
cimgui.c.igNewFrame();
|
||||
cimgui.c.ImGui_NewFrame();
|
||||
|
||||
// Build our UI
|
||||
render: {
|
||||
@@ -1042,7 +1065,7 @@ pub const Inspector = struct {
|
||||
}
|
||||
|
||||
// Render
|
||||
cimgui.c.igRender();
|
||||
cimgui.c.ImGui_Render();
|
||||
}
|
||||
|
||||
// MTLRenderCommandEncoder
|
||||
@@ -1053,7 +1076,7 @@ pub const Inspector = struct {
|
||||
);
|
||||
defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
|
||||
cimgui.ImGui_ImplMetal_RenderDrawData(
|
||||
cimgui.c.igGetDrawData(),
|
||||
cimgui.c.ImGui_GetDrawData(),
|
||||
command_buffer.value,
|
||||
encoder.value,
|
||||
);
|
||||
@@ -1061,22 +1084,24 @@ pub const Inspector = struct {
|
||||
|
||||
pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void {
|
||||
_ = y;
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
|
||||
// Cache our scale because we use it for cursor position calculations.
|
||||
self.content_scale = x;
|
||||
|
||||
// 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, @floatCast(x));
|
||||
const active_style = cimgui.c.igGetStyle();
|
||||
active_style.* = style.*;
|
||||
// Setup a new style and scale it appropriately. We must use the
|
||||
// ImGuiStyle constructor to get proper default values (e.g.,
|
||||
// CurveTessellationTol) rather than zero-initialized values.
|
||||
var style: cimgui.c.ImGuiStyle = undefined;
|
||||
cimgui.ext.ImGuiStyle_ImGuiStyle(&style);
|
||||
cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x));
|
||||
const active_style = cimgui.c.ImGui_GetStyle();
|
||||
active_style.* = style;
|
||||
}
|
||||
|
||||
pub fn updateSize(self: *Inspector, width: u32, height: u32) void {
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) };
|
||||
}
|
||||
|
||||
@@ -1089,8 +1114,8 @@ pub const Inspector = struct {
|
||||
_ = mods;
|
||||
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
|
||||
const imgui_button = switch (button) {
|
||||
.left => cimgui.c.ImGuiMouseButton_Left,
|
||||
@@ -1111,8 +1136,8 @@ pub const Inspector = struct {
|
||||
_ = mods;
|
||||
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddMouseWheelEvent(
|
||||
io,
|
||||
@floatCast(xoff),
|
||||
@@ -1122,8 +1147,8 @@ pub const Inspector = struct {
|
||||
|
||||
pub fn cursorPosCallback(self: *Inspector, x: f64, y: f64) void {
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddMousePosEvent(
|
||||
io,
|
||||
@floatCast(x * self.content_scale),
|
||||
@@ -1133,15 +1158,15 @@ pub const Inspector = struct {
|
||||
|
||||
pub fn focusCallback(self: *Inspector, focused: bool) void {
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddFocusEvent(io, focused);
|
||||
}
|
||||
|
||||
pub fn textCallback(self: *Inspector, text: [:0]const u8) void {
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, text.ptr);
|
||||
}
|
||||
|
||||
@@ -1152,8 +1177,8 @@ pub const Inspector = struct {
|
||||
mods: input.Mods,
|
||||
) !void {
|
||||
self.queueRender();
|
||||
cimgui.c.igSetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
|
||||
// Update all our modifiers
|
||||
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift);
|
||||
@@ -1172,7 +1197,7 @@ pub const Inspector = struct {
|
||||
}
|
||||
|
||||
fn newFrame(self: *Inspector) !void {
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
|
||||
// Determine our delta time
|
||||
const now = try std.time.Instant.now();
|
||||
@@ -1517,8 +1542,11 @@ pub const CAPI = struct {
|
||||
}
|
||||
|
||||
/// Returns the config to use for surfaces that inherit from this one.
|
||||
export fn ghostty_surface_inherited_config(surface: *Surface) Surface.Options {
|
||||
return surface.newSurfaceOptions();
|
||||
export fn ghostty_surface_inherited_config(
|
||||
surface: *Surface,
|
||||
source: apprt.surface.NewSurfaceContext,
|
||||
) Surface.Options {
|
||||
return surface.newSurfaceOptions(source);
|
||||
}
|
||||
|
||||
/// Update the configuration to the provided config for only this surface.
|
||||
@@ -1700,23 +1728,6 @@ pub const CAPI = struct {
|
||||
return @intCast(@as(input.Mods.Backing, @bitCast(result)));
|
||||
}
|
||||
|
||||
/// Returns the current possible commands for a surface
|
||||
/// in the output parameter. The memory is owned by libghostty
|
||||
/// and doesn't need to be freed.
|
||||
export fn ghostty_surface_commands(
|
||||
surface: *Surface,
|
||||
out: *[*]const input.Command.C,
|
||||
len: *usize,
|
||||
) void {
|
||||
// In the future we may use this information to filter
|
||||
// some commands.
|
||||
_ = surface;
|
||||
|
||||
const commands = input.command.defaultsC;
|
||||
out.* = commands.ptr;
|
||||
len.* = commands.len;
|
||||
}
|
||||
|
||||
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
|
||||
/// This will handle the keymap translation and send the appropriate
|
||||
/// key and char events.
|
||||
@@ -1740,13 +1751,18 @@ pub const CAPI = struct {
|
||||
export fn ghostty_surface_key_is_binding(
|
||||
surface: *Surface,
|
||||
event: KeyEvent,
|
||||
c_flags: ?*input.Binding.Flags.C,
|
||||
) bool {
|
||||
const core_event = event.keyEvent().core() orelse {
|
||||
log.warn("error processing key event", .{});
|
||||
return false;
|
||||
};
|
||||
|
||||
return surface.core_surface.keyEventIsBinding(core_event);
|
||||
const flags = surface.core_surface.keyEventIsBinding(
|
||||
core_event,
|
||||
) orelse return false;
|
||||
if (c_flags) |ptr| ptr.* = flags.cval();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Send raw text to the terminal. This is treated like a paste
|
||||
@@ -2149,7 +2165,10 @@ pub const CAPI = struct {
|
||||
if (comptime std.debug.runtime_safety) unreachable;
|
||||
return false;
|
||||
};
|
||||
break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false;
|
||||
break :sel surface.io.terminal.screens.active.selectWord(
|
||||
pin,
|
||||
surface.config.selection_word_chars,
|
||||
) orelse return false;
|
||||
};
|
||||
|
||||
// Read the selection
|
||||
|
||||
@@ -10,4 +10,5 @@ pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
_ = @import("gtk/ext.zig");
|
||||
_ = @import("gtk/key.zig");
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ pub fn clipboardRequest(
|
||||
self: *Self,
|
||||
clipboard_type: apprt.Clipboard,
|
||||
state: apprt.ClipboardRequest,
|
||||
) !void {
|
||||
try self.surface.clipboardRequest(
|
||||
) !bool {
|
||||
return try self.surface.clipboardRequest(
|
||||
clipboard_type,
|
||||
state,
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{
|
||||
.{ .major = 1, .minor = 5, .name = "inspector-window" },
|
||||
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
|
||||
.{ .major = 1, .minor = 2, .name = "search-overlay" },
|
||||
.{ .major = 1, .minor = 2, .name = "key-state-overlay" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree" },
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
|
||||
.{ .major = 1, .minor = 2, .name = "surface" },
|
||||
|
||||
@@ -669,6 +669,9 @@ pub const Application = extern struct {
|
||||
|
||||
.inspector => return Action.controlInspector(target, value),
|
||||
|
||||
.key_sequence => return Action.keySequence(target, value),
|
||||
.key_table => return Action.keyTable(target, value),
|
||||
|
||||
.mouse_over_link => Action.mouseOverLink(target, value),
|
||||
.mouse_shape => Action.mouseShape(target, value),
|
||||
.mouse_visibility => Action.mouseVisibility(target, value),
|
||||
@@ -731,7 +734,7 @@ pub const Application = extern struct {
|
||||
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
|
||||
.command_finished => return Action.commandFinished(target, value),
|
||||
|
||||
.start_search => Action.startSearch(target),
|
||||
.start_search => Action.startSearch(target, value),
|
||||
.end_search => Action.endSearch(target),
|
||||
.search_total => Action.searchTotal(target, value),
|
||||
.search_selected => Action.searchSelected(target, value),
|
||||
@@ -743,7 +746,6 @@ pub const Application = extern struct {
|
||||
.toggle_visibility,
|
||||
.toggle_background_opacity,
|
||||
.cell_size,
|
||||
.key_sequence,
|
||||
.render_inspector,
|
||||
.renderer_health,
|
||||
.color_change,
|
||||
@@ -2236,8 +2238,8 @@ const Action = struct {
|
||||
.{},
|
||||
);
|
||||
|
||||
// Create a new tab
|
||||
win.newTab(parent);
|
||||
// Create a new tab with window context (first tab in new window)
|
||||
win.newTabForWindow(parent);
|
||||
|
||||
// Show the window
|
||||
gtk.Window.present(win.as(gtk.Window));
|
||||
@@ -2437,17 +2439,17 @@ const Action = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn startSearch(target: apprt.Target) void {
|
||||
pub fn startSearch(target: apprt.Target, value: apprt.action.StartSearch) void {
|
||||
switch (target) {
|
||||
.app => {},
|
||||
.surface => |v| v.rt_surface.surface.setSearchActive(true),
|
||||
.surface => |v| v.rt_surface.surface.setSearchActive(true, value.needle),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn endSearch(target: apprt.Target) void {
|
||||
switch (target) {
|
||||
.app => {},
|
||||
.surface => |v| v.rt_surface.surface.setSearchActive(false),
|
||||
.surface => |v| v.rt_surface.surface.setSearchActive(false, ""),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2659,6 +2661,36 @@ const Action = struct {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool {
|
||||
switch (target) {
|
||||
.app => {
|
||||
log.warn("key_sequence action to app is unexpected", .{});
|
||||
return false;
|
||||
},
|
||||
.surface => |core| {
|
||||
core.rt_surface.gobj().keySequenceAction(value) catch |err| {
|
||||
log.warn("error handling key_sequence action: {}", .{err});
|
||||
};
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool {
|
||||
switch (target) {
|
||||
.app => {
|
||||
log.warn("key_table action to app is unexpected", .{});
|
||||
return false;
|
||||
},
|
||||
.surface => |core| {
|
||||
core.rt_surface.gobj().keyTableAction(value) catch |err| {
|
||||
log.warn("error handling key_table action: {}", .{err});
|
||||
};
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// This sets various GTK-related environment variables as necessary
|
||||
|
||||
@@ -10,9 +10,12 @@ const gtk = @import("gtk");
|
||||
const input = @import("../../../input.zig");
|
||||
const gresource = @import("../build/gresource.zig");
|
||||
const key = @import("../key.zig");
|
||||
const WeakRef = @import("../weak_ref.zig").WeakRef;
|
||||
const Common = @import("../class.zig").Common;
|
||||
const Application = @import("application.zig").Application;
|
||||
const Window = @import("window.zig").Window;
|
||||
const Surface = @import("surface.zig").Surface;
|
||||
const Tab = @import("tab.zig").Tab;
|
||||
const Config = @import("config.zig").Config;
|
||||
|
||||
const log = std.log.scoped(.gtk_ghostty_command_palette);
|
||||
@@ -146,34 +149,138 @@ pub const CommandPalette = extern struct {
|
||||
return;
|
||||
};
|
||||
|
||||
const cfg = config.get();
|
||||
|
||||
// Clear existing binds
|
||||
priv.source.removeAll();
|
||||
|
||||
const alloc = Application.default().allocator();
|
||||
var commands: std.ArrayList(*Command) = .{};
|
||||
defer {
|
||||
for (commands.items) |cmd| cmd.unref();
|
||||
commands.deinit(alloc);
|
||||
}
|
||||
|
||||
self.collectJumpCommands(config, &commands) catch |err| {
|
||||
log.warn("failed to collect jump commands: {}", .{err});
|
||||
};
|
||||
|
||||
self.collectRegularCommands(config, &commands, alloc);
|
||||
|
||||
// Sort commands
|
||||
std.mem.sort(*Command, commands.items, {}, struct {
|
||||
fn lessThan(_: void, a: *Command, b: *Command) bool {
|
||||
return compareCommands(a, b);
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
for (commands.items) |cmd| {
|
||||
const cmd_ref = cmd.as(gobject.Object);
|
||||
priv.source.append(cmd_ref);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect regular commands from configuration, filtering out unsupported actions.
|
||||
fn collectRegularCommands(
|
||||
self: *CommandPalette,
|
||||
config: *Config,
|
||||
commands: *std.ArrayList(*Command),
|
||||
alloc: std.mem.Allocator,
|
||||
) void {
|
||||
_ = self;
|
||||
const cfg = config.get();
|
||||
|
||||
for (cfg.@"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,
|
||||
if (!isActionSupportedOnGtk(command.action)) continue;
|
||||
|
||||
else => {},
|
||||
}
|
||||
const cmd = Command.new(config, command) catch |err| {
|
||||
log.warn("failed to create command: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
errdefer cmd.unref();
|
||||
|
||||
const cmd = Command.new(config, command);
|
||||
const cmd_ref = cmd.as(gobject.Object);
|
||||
priv.source.append(cmd_ref);
|
||||
cmd_ref.unref();
|
||||
commands.append(alloc, cmd) catch |err| {
|
||||
log.warn("failed to add command to list: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an action is supported on GTK.
|
||||
fn isActionSupportedOnGtk(action: input.Binding.Action) bool {
|
||||
return switch (action) {
|
||||
.close_all_windows,
|
||||
.toggle_secure_input,
|
||||
.check_for_updates,
|
||||
.redo,
|
||||
.undo,
|
||||
.reset_window_size,
|
||||
.toggle_window_float_on_top,
|
||||
=> false,
|
||||
|
||||
else => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Collect jump commands for all surfaces across all windows.
|
||||
fn collectJumpCommands(
|
||||
self: *CommandPalette,
|
||||
config: *Config,
|
||||
commands: *std.ArrayList(*Command),
|
||||
) !void {
|
||||
_ = self;
|
||||
const app = Application.default();
|
||||
const alloc = app.allocator();
|
||||
|
||||
// Get all surfaces from the core app
|
||||
const core_app = app.core();
|
||||
for (core_app.surfaces.items) |apprt_surface| {
|
||||
const surface = apprt_surface.gobj();
|
||||
const cmd = Command.newJump(config, surface);
|
||||
errdefer cmd.unref();
|
||||
try commands.append(alloc, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two commands for sorting.
|
||||
/// Sorts alphabetically by title (case-insensitive), with colon normalization
|
||||
/// so "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker.
|
||||
fn compareCommands(a: *Command, b: *Command) bool {
|
||||
const a_title = a.propGetTitle() orelse return false;
|
||||
const b_title = b.propGetTitle() orelse return true;
|
||||
|
||||
// Compare case-insensitively with colon normalization
|
||||
for (0..@min(a_title.len, b_title.len)) |i| {
|
||||
// Get characters, replacing ':' with '\t'
|
||||
const a_char = if (a_title[i] == ':') '\t' else a_title[i];
|
||||
const b_char = if (b_title[i] == ':') '\t' else b_title[i];
|
||||
|
||||
const a_lower = std.ascii.toLower(a_char);
|
||||
const b_lower = std.ascii.toLower(b_char);
|
||||
|
||||
if (a_lower != b_lower) {
|
||||
return a_lower < b_lower;
|
||||
}
|
||||
}
|
||||
|
||||
// If one title is a prefix of the other, shorter one comes first
|
||||
if (a_title.len != b_title.len) {
|
||||
return a_title.len < b_title.len;
|
||||
}
|
||||
|
||||
// Titles are equal - use sort_key as tie-breaker if both are jump commands
|
||||
const a_sort_key = switch (a.private().data) {
|
||||
.regular => return false,
|
||||
.jump => |*ja| ja.sort_key,
|
||||
};
|
||||
const b_sort_key = switch (b.private().data) {
|
||||
.regular => return false,
|
||||
.jump => |*jb| jb.sort_key,
|
||||
};
|
||||
|
||||
return a_sort_key < b_sort_key;
|
||||
}
|
||||
|
||||
fn close(self: *CommandPalette) void {
|
||||
const priv = self.private();
|
||||
_ = priv.dialog.close();
|
||||
@@ -234,6 +341,16 @@ pub const CommandPalette = extern struct {
|
||||
self.close();
|
||||
|
||||
const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return;
|
||||
|
||||
// Handle jump commands differently
|
||||
if (cmd.isJump()) {
|
||||
const surface = cmd.getJumpSurface() orelse return;
|
||||
defer surface.unref();
|
||||
surface.present();
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular command - emit trigger signal
|
||||
const action = cmd.getAction() orelse return;
|
||||
|
||||
// Signal that an an action has been selected. Signals are synchronous
|
||||
@@ -413,31 +530,63 @@ const Command = extern struct {
|
||||
};
|
||||
|
||||
pub const Private = struct {
|
||||
/// The configuration we should use to get keybindings.
|
||||
config: ?*Config = null,
|
||||
|
||||
/// Arena used to manage our allocations.
|
||||
arena: ArenaAllocator,
|
||||
|
||||
/// The command.
|
||||
command: ?input.Command = null,
|
||||
|
||||
/// Cache the formatted action.
|
||||
action: ?[:0]const u8 = null,
|
||||
|
||||
/// Cache the formatted action_key.
|
||||
action_key: ?[:0]const u8 = null,
|
||||
data: CommandData,
|
||||
|
||||
pub var offset: c_int = 0;
|
||||
|
||||
pub const CommandData = union(enum) {
|
||||
regular: RegularData,
|
||||
jump: JumpData,
|
||||
};
|
||||
|
||||
pub const RegularData = struct {
|
||||
command: input.Command,
|
||||
action: ?[:0]const u8 = null,
|
||||
action_key: ?[:0]const u8 = null,
|
||||
};
|
||||
|
||||
pub const JumpData = struct {
|
||||
surface: WeakRef(Surface) = .empty,
|
||||
title: ?[:0]const u8 = null,
|
||||
description: ?[:0]const u8 = null,
|
||||
sort_key: usize,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn new(config: *Config, command: input.Command) *Self {
|
||||
pub fn new(config: *Config, command: input.Command) Allocator.Error!*Self {
|
||||
const self = gobject.ext.newInstance(Self, .{
|
||||
.config = config,
|
||||
});
|
||||
errdefer self.unref();
|
||||
|
||||
const priv = self.private();
|
||||
const cloned = try command.clone(priv.arena.allocator());
|
||||
|
||||
priv.data = .{
|
||||
.regular = .{
|
||||
.command = cloned,
|
||||
},
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Create a new jump command that focuses a specific surface.
|
||||
pub fn newJump(config: *Config, surface: *Surface) *Self {
|
||||
const self = gobject.ext.newInstance(Self, .{
|
||||
.config = config,
|
||||
});
|
||||
|
||||
const priv = self.private();
|
||||
priv.command = command.clone(priv.arena.allocator()) catch null;
|
||||
priv.data = .{
|
||||
.jump = .{
|
||||
// TODO: Replace with surface id whenever Ghostty adds one
|
||||
.sort_key = @intFromPtr(surface),
|
||||
},
|
||||
};
|
||||
priv.data.jump.surface.set(surface);
|
||||
|
||||
return self;
|
||||
}
|
||||
@@ -459,6 +608,13 @@ const Command = extern struct {
|
||||
priv.config = null;
|
||||
}
|
||||
|
||||
switch (priv.data) {
|
||||
.regular => {},
|
||||
.jump => |*j| {
|
||||
j.surface.set(null);
|
||||
},
|
||||
}
|
||||
|
||||
gobject.Object.virtual_methods.dispose.call(
|
||||
Class.parent,
|
||||
self.as(Parent),
|
||||
@@ -481,52 +637,99 @@ const Command = extern struct {
|
||||
fn propGetActionKey(self: *Self) ?[:0]const u8 {
|
||||
const priv = self.private();
|
||||
|
||||
if (priv.action_key) |action_key| return action_key;
|
||||
const regular = switch (priv.data) {
|
||||
.regular => |*r| r,
|
||||
.jump => return null,
|
||||
};
|
||||
|
||||
const command = priv.command orelse return null;
|
||||
if (regular.action_key) |action_key| return action_key;
|
||||
|
||||
priv.action_key = std.fmt.allocPrintSentinel(
|
||||
regular.action_key = std.fmt.allocPrintSentinel(
|
||||
priv.arena.allocator(),
|
||||
"{f}",
|
||||
.{command.action},
|
||||
.{regular.command.action},
|
||||
0,
|
||||
) catch null;
|
||||
|
||||
return priv.action_key;
|
||||
return regular.action_key;
|
||||
}
|
||||
|
||||
fn propGetAction(self: *Self) ?[:0]const u8 {
|
||||
const priv = self.private();
|
||||
|
||||
if (priv.action) |action| return action;
|
||||
const regular = switch (priv.data) {
|
||||
.regular => |*r| r,
|
||||
.jump => return null,
|
||||
};
|
||||
|
||||
const command = priv.command orelse return null;
|
||||
if (regular.action) |action| return action;
|
||||
|
||||
const cfg = if (priv.config) |config| config.get() else return null;
|
||||
const keybinds = cfg.keybind.set;
|
||||
|
||||
const alloc = priv.arena.allocator();
|
||||
|
||||
priv.action = action: {
|
||||
regular.action = action: {
|
||||
var buf: [64]u8 = undefined;
|
||||
const trigger = keybinds.getTrigger(command.action) orelse break :action null;
|
||||
const trigger = keybinds.getTrigger(regular.command.action) orelse break :action null;
|
||||
const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null;
|
||||
break :action alloc.dupeZ(u8, accel) catch return null;
|
||||
};
|
||||
|
||||
return priv.action;
|
||||
return regular.action;
|
||||
}
|
||||
|
||||
fn propGetTitle(self: *Self) ?[:0]const u8 {
|
||||
const priv = self.private();
|
||||
const command = priv.command orelse return null;
|
||||
return command.title;
|
||||
|
||||
switch (priv.data) {
|
||||
.regular => |*r| return r.command.title,
|
||||
.jump => |*j| {
|
||||
if (j.title) |title| return title;
|
||||
|
||||
const surface = j.surface.get() orelse return null;
|
||||
defer surface.unref();
|
||||
|
||||
const alloc = priv.arena.allocator();
|
||||
const surface_title = surface.getTitle() orelse "Untitled";
|
||||
|
||||
j.title = std.fmt.allocPrintSentinel(
|
||||
alloc,
|
||||
"Focus: {s}",
|
||||
.{surface_title},
|
||||
0,
|
||||
) catch null;
|
||||
|
||||
return j.title;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn propGetDescription(self: *Self) ?[:0]const u8 {
|
||||
const priv = self.private();
|
||||
const command = priv.command orelse return null;
|
||||
return command.description;
|
||||
|
||||
switch (priv.data) {
|
||||
.regular => |*r| return r.command.description,
|
||||
.jump => |*j| {
|
||||
if (j.description) |desc| return desc;
|
||||
|
||||
const surface = j.surface.get() orelse return null;
|
||||
defer surface.unref();
|
||||
|
||||
const alloc = priv.arena.allocator();
|
||||
|
||||
const title = surface.getTitle() orelse "Untitled";
|
||||
const pwd = surface.getPwd();
|
||||
|
||||
if (pwd) |p| {
|
||||
if (std.mem.indexOf(u8, title, p) == null) {
|
||||
j.description = alloc.dupeZ(u8, p) catch null;
|
||||
}
|
||||
}
|
||||
|
||||
return j.description;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
@@ -536,8 +739,26 @@ const Command = extern struct {
|
||||
/// allocated data that will be freed when this object is.
|
||||
pub fn getAction(self: *Self) ?input.Binding.Action {
|
||||
const priv = self.private();
|
||||
const command = priv.command orelse return null;
|
||||
return command.action;
|
||||
return switch (priv.data) {
|
||||
.regular => |*r| r.command.action,
|
||||
.jump => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if this is a jump command.
|
||||
pub fn isJump(self: *Self) bool {
|
||||
const priv = self.private();
|
||||
return priv.data == .jump;
|
||||
}
|
||||
|
||||
/// Get the jump surface. Returns a strong reference that the caller
|
||||
/// must unref when done, or null if the surface has been destroyed.
|
||||
pub fn getJumpSurface(self: *Self) ?*Surface {
|
||||
const priv = self.private();
|
||||
return switch (priv.data) {
|
||||
.regular => null,
|
||||
.jump => |*j| j.surface.get(),
|
||||
};
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
|
||||
@@ -169,13 +169,17 @@ pub const GlobalShortcuts = extern struct {
|
||||
var trigger_buf: [1024]u8 = undefined;
|
||||
var it = config.keybind.set.bindings.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const leaf = switch (entry.value_ptr.*) {
|
||||
// Global shortcuts can't have leaders
|
||||
const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
.leader => continue,
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
if (!leaf.flags.global) continue;
|
||||
|
||||
// We only allow global keybinds that map to exactly a single
|
||||
// action for now. TODO: remove this restriction
|
||||
const actions = leaf.actionsSlice();
|
||||
if (actions.len != 1) continue;
|
||||
|
||||
const trigger = if (key.xdgShortcutFromTrigger(
|
||||
&trigger_buf,
|
||||
entry.key_ptr.*,
|
||||
@@ -197,7 +201,7 @@ pub const GlobalShortcuts = extern struct {
|
||||
try priv.map.put(
|
||||
alloc,
|
||||
try alloc.dupeZ(u8, trigger),
|
||||
leaf.action,
|
||||
actions[0],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const std = @import("std");
|
||||
const assert = @import("../../../quirks.zig").inlineAssert;
|
||||
|
||||
const cimgui = @import("cimgui");
|
||||
const cimgui = @import("dcimgui");
|
||||
const gl = @import("opengl");
|
||||
const adw = @import("adw");
|
||||
const gdk = @import("gdk");
|
||||
@@ -126,7 +126,7 @@ pub const ImguiWidget = extern struct {
|
||||
log.warn("Dear ImGui context not initialized", .{});
|
||||
return error.ContextNotInitialized;
|
||||
};
|
||||
cimgui.c.igSetCurrentContext(ig_context);
|
||||
cimgui.c.ImGui_SetCurrentContext(ig_context);
|
||||
}
|
||||
|
||||
/// Initialize the frame. Expects that the context is already current.
|
||||
@@ -137,7 +137,7 @@ pub const ImguiWidget = extern struct {
|
||||
|
||||
const priv = self.private();
|
||||
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
|
||||
// Determine our delta time
|
||||
const now = std.time.Instant.now() catch unreachable;
|
||||
@@ -163,7 +163,7 @@ pub const ImguiWidget = extern struct {
|
||||
|
||||
self.setCurrentContext() catch return false;
|
||||
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
|
||||
const mods = key.translateMods(gtk_mods);
|
||||
cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift);
|
||||
@@ -219,14 +219,14 @@ pub const ImguiWidget = extern struct {
|
||||
return;
|
||||
}
|
||||
|
||||
priv.ig_context = cimgui.c.igCreateContext(null) orelse {
|
||||
priv.ig_context = cimgui.c.ImGui_CreateContext(null) orelse {
|
||||
log.warn("unable to initialize Dear ImGui context", .{});
|
||||
return;
|
||||
};
|
||||
self.setCurrentContext() catch return;
|
||||
|
||||
// Setup some basic config
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
io.BackendPlatformName = "ghostty_gtk";
|
||||
|
||||
// Realize means that our OpenGL context is ready, so we can now
|
||||
@@ -247,7 +247,7 @@ pub const ImguiWidget = extern struct {
|
||||
/// Handle a request to resize the GLArea
|
||||
fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void {
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
const scale_factor = area.as(gtk.Widget).getScaleFactor();
|
||||
|
||||
// Our display size is always unscaled. We'll do the scaling in the
|
||||
@@ -255,12 +255,14 @@ pub const ImguiWidget = extern struct {
|
||||
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.*;
|
||||
// Setup a new style and scale it appropriately. We must use the
|
||||
// ImGuiStyle constructor to get proper default values (e.g.,
|
||||
// CurveTessellationTol) rather than zero-initialized values.
|
||||
var style: cimgui.c.ImGuiStyle = undefined;
|
||||
cimgui.ext.ImGuiStyle_ImGuiStyle(&style);
|
||||
cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatFromInt(scale_factor));
|
||||
const active_style = cimgui.c.ImGui_GetStyle();
|
||||
active_style.* = style;
|
||||
}
|
||||
|
||||
/// Handle a request to render the contents of our GLArea
|
||||
@@ -273,33 +275,33 @@ pub const ImguiWidget = extern struct {
|
||||
for (0..2) |_| {
|
||||
cimgui.ImGui_ImplOpenGL3_NewFrame();
|
||||
self.newFrame();
|
||||
cimgui.c.igNewFrame();
|
||||
cimgui.c.ImGui_NewFrame();
|
||||
|
||||
// Call the virtual method to draw the UI.
|
||||
self.render();
|
||||
|
||||
// Render
|
||||
cimgui.c.igRender();
|
||||
cimgui.c.ImGui_Render();
|
||||
}
|
||||
|
||||
// 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());
|
||||
cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData());
|
||||
|
||||
return @intFromBool(true);
|
||||
}
|
||||
|
||||
fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddFocusEvent(io, true);
|
||||
self.queueRender();
|
||||
}
|
||||
|
||||
fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddFocusEvent(io, false);
|
||||
self.queueRender();
|
||||
}
|
||||
@@ -345,7 +347,7 @@ pub const ImguiWidget = extern struct {
|
||||
) callconv(.c) void {
|
||||
self.queueRender();
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
|
||||
if (translateMouseButton(gdk_button)) |button| {
|
||||
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true);
|
||||
@@ -361,7 +363,7 @@ pub const ImguiWidget = extern struct {
|
||||
) callconv(.c) void {
|
||||
self.queueRender();
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton();
|
||||
if (translateMouseButton(gdk_button)) |button| {
|
||||
cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false);
|
||||
@@ -376,7 +378,7 @@ pub const ImguiWidget = extern struct {
|
||||
) callconv(.c) void {
|
||||
self.queueRender();
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
const scale_factor = self.getScaleFactor();
|
||||
cimgui.c.ImGuiIO_AddMousePosEvent(
|
||||
io,
|
||||
@@ -393,7 +395,7 @@ pub const ImguiWidget = extern struct {
|
||||
) callconv(.c) c_int {
|
||||
self.queueRender();
|
||||
self.setCurrentContext() catch return @intFromBool(false);
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddMouseWheelEvent(
|
||||
io,
|
||||
@floatCast(x),
|
||||
@@ -409,7 +411,7 @@ pub const ImguiWidget = extern struct {
|
||||
) callconv(.c) void {
|
||||
self.queueRender();
|
||||
self.setCurrentContext() catch return;
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
|
||||
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
|
||||
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user