Merge branch 'main' into localize-nautilus-script

This commit is contained in:
David Matos
2026-01-21 14:28:01 +01:00
222 changed files with 42432 additions and 19696 deletions

2
.gitattributes vendored
View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -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"
}
}

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 = (

View File

@@ -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

View 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)
}
}

View 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
}
}

View 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))
}
}

View 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!")
}
}

View 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)")
}
}
}

View File

@@ -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 {

View 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
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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) {

View 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]
}
}

View File

@@ -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) }
}
}

View File

@@ -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 []
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 }

View 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
}
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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)
}
}
}

View File

@@ -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))
}

View File

@@ -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"
]

View File

@@ -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 }
}
}
}

View 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?
}
}

View File

@@ -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.

View 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"
}

View 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)
}
}
}

View File

@@ -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 }

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View 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")
}
}
}

View 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)
}
}

View File

@@ -26,6 +26,7 @@
wasmtime,
wraptest,
zig,
zig_0_15,
zip,
llvmPackages_latest,
bzip2,

View File

@@ -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}"
];

View File

@@ -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)

View File

@@ -8,7 +8,7 @@
./common.nix
];
services.xserver = {
services = {
displayManager = {
gdm = {
enable = true;

View File

@@ -1,9 +0,0 @@
{...}: {
imports = [
./common-gnome.nix
];
services.displayManager = {
defaultSession = "gnome-xorg";
};
}

View File

@@ -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);
}

View File

@@ -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" },
},
}

View File

@@ -1,4 +0,0 @@
pub const c = @cImport({
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
@cInclude("cimgui.h");
});

View File

@@ -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 {}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

199
pkg/dcimgui/build.zig Normal file
View 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
View 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
View 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
View 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;
}

View File

@@ -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

View File

@@ -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(""),

View File

@@ -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",

View File

@@ -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 = &.{

View File

@@ -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(""));

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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,
);

View File

@@ -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" },

View File

@@ -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

View File

@@ -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(),
};
}
//---------------------------------------------------------------

View File

@@ -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],
);
}

View File

@@ -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