Merge remote-tracking branch 'upstream/main' into harfbuzz-positions

This commit is contained in:
Jacob Sandlund
2026-01-14 08:56:56 -05:00
148 changed files with 31094 additions and 17452 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

@@ -217,6 +217,7 @@ jobs:
x86_64-macos,
aarch64-linux,
x86_64-linux,
x86_64-linux-musl,
x86_64-windows,
wasm32-freestanding,
]

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://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
.hash = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
.hash = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV",
.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-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": {
"N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
"hash": "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
"hash": "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk="
},
"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-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU";
name = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz";
hash = "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz";
hash = "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk=";
};
}
{
@@ -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,17 +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-20251222-150520-0add1e1.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.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
@@ -26,10 +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/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,81 +17,45 @@
# 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
from gi.repository import Nautilus, GObject, Gio
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://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU",
"sha256": "70c2040d91587629426af84be69590ea345eac65054992232f06f1368b2c49e8"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV",
"sha256": "6d6290c51820cff64bafac3466dfe14e79c001bd77550595ef449c0ac3452199"
},
{
"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

@@ -102,6 +102,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,
@@ -416,6 +423,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;
@@ -428,6 +441,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 {
@@ -882,7 +896,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;
@@ -1035,7 +1049,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);
@@ -1051,7 +1065,9 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e);
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

@@ -66,11 +66,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 +120,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",
@@ -141,14 +144,15 @@
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",
@@ -165,6 +169,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,
@@ -186,7 +191,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/iOS/iOSApp.swift,
Ghostty/SurfaceView_UIKit.swift,
"Ghostty/Surface View/SurfaceView_UIKit.swift",
);
target = A5B30530299BEAAA0047F10C /* Ghostty */;
};

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?
@@ -615,6 +617,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 +946,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 +957,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 +973,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

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

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

@@ -773,7 +773,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)),
]
)
@@ -810,7 +810,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)),
]
)
@@ -839,7 +839,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)),
]
)
@@ -1869,11 +1869,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

@@ -658,9 +658,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

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

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

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

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

@@ -224,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
}
}
@@ -467,7 +475,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
@@ -638,6 +648,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) {
@@ -659,6 +672,7 @@ extension Ghostty {
}
}
}
self.context = config.context
}
/// Provides a C-compatible ghostty configuration within a closure. The configuration
@@ -692,6 +706,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

View File

@@ -1181,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.
@@ -1201,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
@@ -1518,6 +1528,22 @@ extension Ghostty {
}
}
@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"
@@ -1654,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?
@@ -1662,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
@@ -1671,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)
@@ -2213,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
@@ -63,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,12 +10,6 @@ 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 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() {
@@ -36,3 +30,53 @@ extension NSWindow {
}
}
}
// 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

@@ -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,129 +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 (target.result.os.tag == .freebsd) {
try flags.append(b.allocator, "-fPIC");
}
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,7 +90,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
"-fno-sanitize=undefined",
});
if (target.result.os.tag == .freebsd) {
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
try flags.append(b.allocator, "-fPIC");
}

View File

@@ -66,7 +66,7 @@ fn buildGlslang(
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
try flags.append(b.allocator, "-fPIC");
}

View File

@@ -73,7 +73,7 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
try flags.append(b.allocator, "-fPIC");
}

View File

@@ -32,7 +32,7 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
try flags.append(b.allocator, "-fPIC");
}

View File

@@ -74,7 +74,7 @@ fn buildSpirvCross(
"-fno-sanitize-trap=undefined",
});
if (target.result.os.tag == .freebsd) {
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
try flags.append(b.allocator, "-fPIC");
}

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-07-22 17:18+0000\n"
"PO-Revision-Date: 2025-08-25 19:38+0100\n"
"Last-Translator: Robin <r@rpfaeffle.com>\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"
@@ -320,4 +321,4 @@ msgstr "Ghostty-Entwickler"
#: src/apprt/gtk/inspector.zig:144
msgid "Ghostty: Terminal Inspector"
msgstr ""
msgstr "Ghostty: Terminalinspektor"

View File

@@ -327,12 +327,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,
@@ -347,7 +348,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();
@@ -408,6 +409,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.
@@ -1026,7 +1028,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) {
@@ -1599,10 +1601,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 .{
@@ -1613,7 +1615,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 };
@@ -2576,38 +2578,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 => {},
}
// If we're in a sequence, check the sequence set
if (self.keyboard.sequence_set) |set| {
return 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) != null) {
return true;
// 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;
}
}
// Check the root set
return self.config.keybind.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;
}
}
// 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();
@@ -2645,7 +2669,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();
@@ -4141,9 +4164,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.*);
};
if (sel_) |sel| {
try self.io.terminal.screens.active.select(sel);
try self.queueRender();
@@ -4173,7 +4211,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
@@ -4251,7 +4289,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.
@@ -4266,7 +4304,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);
},
}
@@ -4331,16 +4369,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: {
@@ -4361,14 +4401,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,
@@ -4383,12 +4436,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) {
@@ -4396,7 +4449,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,
};
}
}
@@ -4427,11 +4483,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);
@@ -4444,7 +4500,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;
};
@@ -5163,6 +5219,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
@@ -5278,11 +5344,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});
@@ -5292,7 +5358,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;
};
@@ -5330,12 +5396,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 = {} },
),
@@ -5794,6 +5860,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
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"),
@@ -6049,11 +6123,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) {
@@ -6061,14 +6139,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

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

View File

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

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

@@ -734,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),
@@ -2238,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));
@@ -2439,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, ""),
}
}

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

View File

@@ -250,6 +250,13 @@ pub const SearchOverlay = extern struct {
priv.active = active;
}
// Set contents of search
pub fn setSearchContents(self: *Self, content: [:0]const u8) void {
const priv = self.private();
priv.search_entry.as(gtk.Editable).setText(content);
signals.@"search-changed".impl.emit(self, null, .{content}, null);
}
/// Set the total number of search matches.
pub fn setSearchTotal(self: *Self, total: ?usize) void {
const priv = self.private();

View File

@@ -219,7 +219,7 @@ pub const SplitTree = extern struct {
// Inherit properly if we were asked to.
if (parent_) |p| {
if (p.core()) |core| {
surface.setParent(core);
surface.setParent(core, .split);
}
}

View File

@@ -671,6 +671,9 @@ pub const Surface = extern struct {
error_page: *adw.StatusPage,
terminal_page: *gtk.Overlay,
/// The context for this surface (window, tab, or split)
context: apprt.surface.NewSurfaceContext = .window,
pub var offset: c_int = 0;
};
@@ -696,6 +699,7 @@ pub const Surface = extern struct {
pub fn setParent(
self: *Self,
parent: *CoreSurface,
context: apprt.surface.NewSurfaceContext,
) void {
const priv = self.private();
@@ -706,6 +710,9 @@ pub const Surface = extern struct {
return;
}
// Store the context so initSurface can use it
priv.context = context;
// Setup our font size
const font_size_ptr = glib.ext.create(font.face.DesiredSize);
errdefer glib.ext.destroy(font_size_ptr);
@@ -716,10 +723,8 @@ pub const Surface = extern struct {
// Remainder needs a config. If there is no config we just assume
// we aren't inheriting any of these values.
if (priv.config) |config_obj| {
const config = config_obj.get();
// Setup our pwd if configured to inherit
if (config.@"window-inherit-working-directory") {
// Setup our cwd if configured to inherit
if (apprt.surface.shouldInheritWorkingDirectory(context, config_obj.get())) {
if (parent.rt_surface.surface.getPwd()) |pwd| {
priv.pwd = glib.ext.dupeZ(u8, pwd);
self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec);
@@ -1666,8 +1671,8 @@ pub const Surface = extern struct {
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
try Clipboard.request(
) !bool {
return try Clipboard.request(
self,
clipboard_type,
state,
@@ -2086,7 +2091,7 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
pub fn setSearchActive(self: *Self, active: bool) void {
pub fn setSearchActive(self: *Self, active: bool, needle: [:0]const u8) void {
const priv = self.private();
var value = gobject.ext.Value.newFrom(active);
defer value.unset();
@@ -2096,6 +2101,10 @@ pub const Surface = extern struct {
&value,
);
if (!std.mem.eql(u8, needle, "")) {
priv.search_overlay.setSearchContents(needle);
}
if (active) {
priv.search_overlay.grabFocus();
}
@@ -3206,6 +3215,7 @@ pub const Surface = extern struct {
var config = try apprt.surface.newConfig(
app.core(),
priv.config.?.get(),
priv.context,
);
defer config.deinit();
@@ -3623,16 +3633,30 @@ const Clipboard = struct {
/// Request data from the clipboard (read the clipboard). This
/// completes asynchronously and will call the `completeClipboardRequest`
/// core surface API when done.
///
/// Returns true if the request was started, false if the clipboard
/// doesn't contain text (allowing performable keybinds to pass through).
pub fn request(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) Allocator.Error!void {
) Allocator.Error!bool {
// Get our requested clipboard
const clipboard = get(
self.private().gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
) orelse return false;
// For paste requests, check if clipboard has text format available.
// This is a synchronous check that allows performable keybinds to
// pass through when the clipboard contains non-text content (e.g., images).
if (state == .paste) {
const formats = clipboard.getFormats();
if (formats.containGtype(gobject.ext.types.string) == 0) {
log.debug("clipboard has no text format, not starting paste request", .{});
return false;
}
}
// Allocate our userdata
const alloc = Application.default().allocator();
@@ -3652,6 +3676,8 @@ const Clipboard = struct {
clipboardReadText,
ud,
);
return true;
}
/// Paste explicit text directly into the surface, regardless of the

View File

@@ -161,8 +161,12 @@ pub const Tab = extern struct {
/// ever created for a tab. If a surface was already created this does
/// nothing.
pub fn setParent(self: *Self, parent: *CoreSurface) void {
self.setParentWithContext(parent, .tab);
}
pub fn setParentWithContext(self: *Self, parent: *CoreSurface, context: apprt.surface.NewSurfaceContext) void {
if (self.getActiveSurface()) |surface| {
surface.setParent(parent);
surface.setParent(parent, context);
}
}

View File

@@ -361,10 +361,14 @@ pub const Window = extern struct {
/// at the position dictated by the `window-new-tab-position` config.
/// The new tab will be selected.
pub fn newTab(self: *Self, parent_: ?*CoreSurface) void {
_ = self.newTabPage(parent_);
_ = self.newTabPage(parent_, .tab);
}
fn newTabPage(self: *Self, parent_: ?*CoreSurface) *adw.TabPage {
pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface) void {
_ = self.newTabPage(parent_, .window);
}
fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext) *adw.TabPage {
const priv = self.private();
const tab_view = priv.tab_view;
@@ -372,7 +376,9 @@ pub const Window = extern struct {
const tab = gobject.ext.newInstance(Tab, .{
.config = priv.config,
});
if (parent_) |p| tab.setParent(p);
if (parent_) |p| {
tab.setParentWithContext(p, context);
}
// Get the position that we should insert the new tab at.
const config = if (priv.config) |v| v.get() else {
@@ -1231,7 +1237,7 @@ pub const Window = extern struct {
_: *adw.TabOverview,
self: *Self,
) callconv(.c) *adw.TabPage {
return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null);
return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab);
}
fn tabOverviewOpen(

View File

@@ -159,12 +159,28 @@ pub const Mailbox = struct {
}
};
/// Context for new surface creation to determine inheritance behavior
pub const NewSurfaceContext = enum(c_int) {
window = 0,
tab = 1,
split = 2,
};
pub fn shouldInheritWorkingDirectory(context: NewSurfaceContext, config: *const Config) bool {
return switch (context) {
.window => config.@"window-inherit-working-directory",
.tab => config.@"tab-inherit-working-directory",
.split => config.@"split-inherit-working-directory",
};
}
/// Returns a new config for a surface for the given app that should be
/// used for any new surfaces. The resulting config should be deinitialized
/// after the surface is initialized.
pub fn newConfig(
app: *const App,
config: *const Config,
context: NewSurfaceContext,
) Allocator.Error!Config {
// Create a shallow clone
var copy = config.shallowClone(app.alloc);
@@ -175,7 +191,7 @@ pub fn newConfig(
// Get our previously focused surface for some inherited values.
const prev = app.focusedSurface();
if (prev) |p| {
if (config.@"window-inherit-working-directory") {
if (shouldInheritWorkingDirectory(context, config)) {
if (try p.pwd(alloc)) |pwd| {
copy.@"working-directory" = pwd;
}

View File

@@ -100,8 +100,8 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
error.ReadFailed => return error.BenchmarkFailed,
};
for (osc_buf[0..len]) |c| self.parser.next(c);
_ = self.parser.end(std.ascii.control_code.bel);
for (osc_buf[0..len]) |c| @call(.always_inline, Parser.next, .{ &self.parser, c });
std.mem.doNotOptimizeAway(self.parser.end(std.ascii.control_code.bel));
self.parser.reset();
}
}

View File

@@ -135,29 +135,28 @@ pub fn add(
// Every exe needs the terminal options
self.config.terminalOptions().add(b, step.root_module);
// Freetype
// Freetype. We always include this even if our font backend doesn't
// use it because Dear Imgui uses Freetype.
_ = b.systemIntegrationOption("freetype", .{}); // Shows it in help
if (self.config.font_backend.hasFreetype()) {
if (b.lazyDependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
})) |freetype_dep| {
step.root_module.addImport(
"freetype",
freetype_dep.module("freetype"),
);
if (b.lazyDependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
})) |freetype_dep| {
step.root_module.addImport(
"freetype",
freetype_dep.module("freetype"),
);
if (b.systemIntegrationOption("freetype", .{})) {
step.linkSystemLibrary2("bzip2", dynamic_link_opts);
step.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
step.linkLibrary(freetype_dep.artifact("freetype"));
try static_libs.append(
b.allocator,
freetype_dep.artifact("freetype").getEmittedBin(),
);
}
if (b.systemIntegrationOption("freetype", .{})) {
step.linkSystemLibrary2("bzip2", dynamic_link_opts);
step.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
step.linkLibrary(freetype_dep.artifact("freetype"));
try static_libs.append(
b.allocator,
freetype_dep.artifact("freetype").getEmittedBin(),
);
}
}
@@ -479,15 +478,19 @@ pub fn add(
}
// cimgui
if (b.lazyDependency("cimgui", .{
if (b.lazyDependency("dcimgui", .{
.target = target,
.optimize = optimize,
})) |cimgui_dep| {
step.root_module.addImport("cimgui", cimgui_dep.module("cimgui"));
step.linkLibrary(cimgui_dep.artifact("cimgui"));
.freetype = true,
.@"backend-metal" = target.result.os.tag.isDarwin(),
.@"backend-osx" = target.result.os.tag == .macos,
.@"backend-opengl3" = target.result.os.tag != .macos,
})) |dep| {
step.root_module.addImport("dcimgui", dep.module("dcimgui"));
step.linkLibrary(dep.artifact("dcimgui"));
try static_libs.append(
b.allocator,
cimgui_dep.artifact("cimgui").getEmittedBin(),
dep.artifact("dcimgui").getEmittedBin(),
);
}

View File

@@ -17,6 +17,7 @@ const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const global_state = &@import("../global.zig").state;
const deepEqual = @import("../datastruct/comparison.zig").deepEqual;
const fontpkg = @import("../font/main.zig");
const inputpkg = @import("../input.zig");
const internal_os = @import("../os/main.zig");
@@ -37,6 +38,7 @@ const RepeatableStringMap = @import("RepeatableStringMap.zig");
pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath;
const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
const KeyRemapSet = @import("../input/key_mods.zig").RemapSet;
// We do this instead of importing all of terminal/main.zig to
// limit the dependency graph. This is important because some things
@@ -1757,6 +1759,52 @@ class: ?[:0]const u8 = null,
/// Key tables are available since Ghostty 1.3.0.
keybind: Keybinds = .{},
/// Remap modifier keys within Ghostty. This allows you to swap or reassign
/// modifier keys at the application level without affecting system-wide
/// settings.
///
/// The format is `from=to` where both `from` and `to` are modifier key names.
/// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS:
/// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc.
///
/// This will NOT change keyboard layout or key encodings outside of Ghostty.
/// For example, on macOS, `option+a` may still produce `å` even if `option` is
/// remapped to `ctrl`. Desktop environments usually handle key layout long
/// before Ghostty receives the key events.
///
/// Example:
///
/// key-remap = ctrl=super
/// key-remap = left_control=right_alt
///
/// Important notes:
///
/// * This is a one-way remap. If you remap `ctrl=super`, then the physical
/// Ctrl key acts as Super, but the Super key remains Super.
///
/// * Remaps are not transitive. If you remap `ctrl=super` and `alt=ctrl`,
/// pressing Alt will produce Ctrl, NOT Super.
///
/// * This affects both keybind matching and terminal input encoding.
/// This does NOT impact keyboard layout or how keys are interpreted
/// prior to Ghostty receiving them. For example, `option+a` on macOS
/// may still produce `å` even if `option` is remapped to `ctrl`.
///
/// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys.
/// Use sided names (e.g. `left_ctrl`) to remap only one side.
///
/// There are other edge case scenarios that may not behave as expected
/// but are working as intended the way this feature is designed:
///
/// * On macOS, bindings in the main menu will trigger before any remapping
/// is done. This is because macOS itself handles menu activation and
/// this happens before Ghostty receives the key event. To workaround
/// this, you should unbind the menu items and rebind them using your
/// desired modifier.
///
/// This configuration can be repeated to specify multiple remaps.
@"key-remap": KeyRemapSet = .empty,
/// Horizontal window padding. This applies padding between the terminal cells
/// and the left and right window borders. The value is in points, meaning that
/// it will be scaled appropriately for screen DPI.
@@ -1845,11 +1893,21 @@ keybind: Keybinds = .{},
/// This setting is only supported currently on macOS.
@"window-vsync": bool = true,
/// If true, new windows and tabs will inherit the working directory of the
/// If true, new windows will inherit the working directory of the
/// previously focused window. If no window was previously focused, the default
/// working directory will be used (the `working-directory` option).
@"window-inherit-working-directory": bool = true,
/// If true, new tabs will inherit the working directory of the
/// previously focused tab. If no tab was previously focused, the default
/// working directory will be used (the `working-directory` option).
@"tab-inherit-working-directory": bool = true,
/// If true, new split panes will inherit the working directory of the
/// previously focused split. If no split was previously focused, the default
/// working directory will be used (the `working-directory` option).
@"split-inherit-working-directory": bool = true,
/// If true, new windows and tabs will inherit the font size of the previously
/// focused window. If no window was previously focused, the default font size
/// will be used. If this is false, the default font size specified in the
@@ -2783,6 +2841,22 @@ keybind: Keybinds = .{},
/// the same time as the `iTime` uniform, allowing you to compute the
/// time since the change by subtracting this from `iTime`.
///
/// * `float iTimeFocus` - Timestamp when the surface last gained iFocus.
///
/// When the surface gains focus, this is set to the current value of
/// `iTime`, similar to how `iTimeCursorChange` works. This allows you
/// to compute the time since focus was gained or lost by calculating
/// `iTime - iTimeFocus`. Use this to create animations that restart
/// when the terminal regains focus.
///
/// * `int iFocus` - Current focus state of the surface.
///
/// Set to 1.0 when the surface is focused, 0.0 when unfocused. This
/// allows shaders to detect unfocused state and avoid animation artifacts
/// from large time deltas caused by infrequent "deceptive frames"
/// (e.g., modifier key presses, link hover events in unfocused split panes).
/// Check `iFocus > 0` to determine if the surface is currently focused.
///
/// If the shader fails to compile, the shader will be ignored. Any errors
/// related to shader compilation will not show up as configuration errors
/// and only show up in the log, since shader compilation happens after
@@ -2861,7 +2935,7 @@ keybind: Keybinds = .{},
/// Display a border around the alerted surface until the terminal is
/// re-focused or interacted with (such as on keyboard input).
///
/// GTK only.
/// Available since: 1.2.0 on GTK, 1.2.1 on macOS
///
/// Example: `audio`, `no-audio`, `system`, `no-system`
///
@@ -4047,7 +4121,7 @@ pub fn changeConditionalState(
// Conditional set contains the keys that this config uses. So we
// only continue if we use this key.
if (self._conditional_set.contains(key) and !equalField(
if (self._conditional_set.contains(key) and !deepEqual(
@TypeOf(@field(self._conditional_state, field.name)),
@field(self._conditional_state, field.name),
@field(new, field.name),
@@ -4410,6 +4484,9 @@ pub fn finalize(self: *Config) !void {
}
self.@"faint-opacity" = std.math.clamp(self.@"faint-opacity", 0.0, 1.0);
// Finalize key remapping set for efficient lookups
self.@"key-remap".finalize();
}
/// Callback for src/cli/args.zig to allow us to handle special cases
@@ -4750,7 +4827,7 @@ pub fn changed(self: *const Config, new: *const Config, comptime key: Key) bool
const old_value = @field(self, field.name);
const new_value = @field(new, field.name);
return !equalField(field.type, old_value, new_value);
return !deepEqual(field.type, old_value, new_value);
}
/// This yields a key for every changed field between old and new.
@@ -4778,91 +4855,6 @@ pub const ChangeIterator = struct {
}
};
/// A config-specific helper to determine if two values of the same
/// type are equal. This isn't the same as std.mem.eql or std.testing.equals
/// because we expect structs to implement their own equality.
///
/// This also doesn't support ALL Zig types, because we only add to it
/// as we need types for the config.
fn equalField(comptime T: type, old: T, new: T) bool {
// Do known named types first
switch (T) {
inline []const u8,
[:0]const u8,
=> return std.mem.eql(u8, old, new),
[]const [:0]const u8,
=> {
if (old.len != new.len) return false;
for (old, new) |a, b| {
if (!std.mem.eql(u8, a, b)) return false;
}
return true;
},
else => {},
}
// Back into types of types
switch (@typeInfo(T)) {
.void => return true,
inline .bool,
.int,
.float,
.@"enum",
=> return old == new,
.optional => |info| {
if (old == null and new == null) return true;
if (old == null or new == null) return false;
return equalField(info.child, old.?, new.?);
},
.@"struct" => |info| {
if (@hasDecl(T, "equal")) return old.equal(new);
// If a struct doesn't declare an "equal" function, we fall back
// to a recursive field-by-field compare.
inline for (info.fields) |field_info| {
if (!equalField(
field_info.type,
@field(old, field_info.name),
@field(new, field_info.name),
)) return false;
}
return true;
},
.@"union" => |info| {
if (@hasDecl(T, "equal")) return old.equal(new);
const tag_type = info.tag_type.?;
const old_tag = std.meta.activeTag(old);
const new_tag = std.meta.activeTag(new);
if (old_tag != new_tag) return false;
inline for (info.fields) |field_info| {
if (@field(tag_type, field_info.name) == old_tag) {
return equalField(
field_info.type,
@field(old, field_info.name),
@field(new, field_info.name),
);
}
}
unreachable;
},
else => {
@compileLog(T);
@compileError("unsupported field type");
},
}
}
/// This runs a heuristic to determine if we are likely running
/// Ghostty in a CLI environment. We need this to change some behaviors.
/// We should keep the set of behaviors that depend on this as small
@@ -6430,6 +6422,12 @@ pub const Keybinds = struct {
.{ .key = .{ .physical = .page_down }, .mods = .{ .super = true } },
.{ .scroll_page_down = {} },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'j' }, .mods = .{ .super = true } },
.{ .scroll_to_selection = {} },
.{ .performable = true },
);
// Semantic prompts
try self.set.put(
@@ -6569,6 +6567,12 @@ pub const Keybinds = struct {
.start_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } },
.search_selection,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } },
@@ -6797,7 +6801,7 @@ pub const Keybinds = struct {
const self_leaf = self_entry.value_ptr.*.leaf;
const other_leaf = other_entry.value_ptr.*.leaf;
if (!equalField(
if (!deepEqual(
inputpkg.Binding.Set.Leaf,
self_leaf,
other_leaf,
@@ -6811,7 +6815,7 @@ pub const Keybinds = struct {
if (self_chain.flags != other_chain.flags) return false;
if (self_chain.actions.items.len != other_chain.actions.items.len) return false;
for (self_chain.actions.items, other_chain.actions.items) |a1, a2| {
if (!equalField(
if (!deepEqual(
inputpkg.Binding.Action,
a1,
a2,
@@ -6826,7 +6830,7 @@ pub const Keybinds = struct {
/// Like formatEntry but has an option to include docs.
pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void {
if (self.set.bindings.size == 0 and self.tables.count() == 0) {
if (self.set.bindings.count() == 0 and self.tables.count() == 0) {
try formatter.formatEntry(void, {});
return;
}
@@ -6928,8 +6932,8 @@ pub const Keybinds = struct {
// Note they turn into translated keys because they match
// their ASCII mapping.
const want =
\\keybind = ctrl+z>2=goto_tab:2
\\keybind = ctrl+z>1=goto_tab:1
\\keybind = ctrl+z>2=goto_tab:2
\\
;
try std.testing.expectEqualStrings(want, buf.written());
@@ -6953,9 +6957,9 @@ pub const Keybinds = struct {
// NB: This does not currently retain the order of the keybinds.
const want =
\\a = ctrl+a>ctrl+c>t=new_tab
\\a = ctrl+a>ctrl+b>w=close_window
\\a = ctrl+a>ctrl+b>n=new_window
\\a = ctrl+a>ctrl+b>w=close_window
\\a = ctrl+a>ctrl+c>t=new_tab
\\a = ctrl+b>ctrl+d>a=previous_tab
\\
;

View File

@@ -1,11 +1,102 @@
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
const std = @import("std");
const testing = std.testing;
/// A deep equality comparison function that works for most types. We
/// add types as necessary. It defers to `equal` decls on types that support
/// decls.
pub fn deepEqual(comptime T: type, old: T, new: T) bool {
// Do known named types first
switch (T) {
inline []const u8,
[:0]const u8,
=> return std.mem.eql(u8, old, new),
[]const [:0]const u8,
=> {
if (old.len != new.len) return false;
for (old, new) |a, b| {
if (!std.mem.eql(u8, a, b)) return false;
}
return true;
},
else => {},
}
// Back into types of types
switch (@typeInfo(T)) {
.void => return true,
inline .bool,
.int,
.float,
.@"enum",
=> return old == new,
.optional => |info| {
if (old == null and new == null) return true;
if (old == null or new == null) return false;
return deepEqual(info.child, old.?, new.?);
},
.array => |info| for (old, new) |old_elem, new_elem| {
if (!deepEqual(
info.child,
old_elem,
new_elem,
)) return false;
} else return true,
.@"struct" => |info| {
if (@hasDecl(T, "equal")) return old.equal(new);
// If a struct doesn't declare an "equal" function, we fall back
// to a recursive field-by-field compare.
inline for (info.fields) |field_info| {
if (!deepEqual(
field_info.type,
@field(old, field_info.name),
@field(new, field_info.name),
)) return false;
}
return true;
},
.@"union" => |info| {
if (@hasDecl(T, "equal")) return old.equal(new);
const tag_type = info.tag_type.?;
const old_tag = std.meta.activeTag(old);
const new_tag = std.meta.activeTag(new);
if (old_tag != new_tag) return false;
inline for (info.fields) |field_info| {
if (@field(tag_type, field_info.name) == old_tag) {
return deepEqual(
field_info.type,
@field(old, field_info.name),
@field(new, field_info.name),
);
}
}
unreachable;
},
else => {
@compileLog(T);
@compileError("unsupported field type");
},
}
}
/// Generic, recursive equality testing utility using approximate comparison for
/// floats and equality for everything else
///
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
/// Based on `testing.expectEqual` and `testing.expectEqualSlices`.
///
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
@@ -59,7 +150,7 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
if (union_info.tag_type == null) {
// untagged unions can only be compared bitwise,
// so expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
}
@@ -69,7 +160,7 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
const expectedTag = @as(Tag, expected);
const actualTag = @as(Tag, actual);
std.testing.expectEqual(expectedTag, actualTag) catch {
testing.expectEqual(expectedTag, actualTag) catch {
return error.TestExpectedApproxEqual;
};
@@ -84,23 +175,23 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
};
// we only reach this point if there's at least one null or error,
// in which case expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
},
// fall back to expectEqual for everything else
else => std.testing.expectEqual(expected, actual) catch {
else => testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
},
}
}
/// Copy of std.testing.print (not public)
/// Copy of testing.print (not public)
fn print(comptime fmt: []const u8, args: anytype) void {
if (@inComptime()) {
@compileError(std.fmt.comptimePrint(fmt, args));
} else if (std.testing.backend_can_print) {
} else if (testing.backend_can_print) {
std.debug.print(fmt, args);
}
}
@@ -145,3 +236,195 @@ test "expectApproxEqual struct" {
try expectApproxEqual(a, b);
}
test "deepEqual void" {
try testing.expect(deepEqual(void, {}, {}));
}
test "deepEqual bool" {
try testing.expect(deepEqual(bool, true, true));
try testing.expect(deepEqual(bool, false, false));
try testing.expect(!deepEqual(bool, true, false));
try testing.expect(!deepEqual(bool, false, true));
}
test "deepEqual int" {
try testing.expect(deepEqual(i32, 42, 42));
try testing.expect(deepEqual(i32, -100, -100));
try testing.expect(!deepEqual(i32, 42, 43));
try testing.expect(deepEqual(u64, 0, 0));
try testing.expect(!deepEqual(u64, 0, 1));
}
test "deepEqual float" {
try testing.expect(deepEqual(f32, 1.0, 1.0));
try testing.expect(!deepEqual(f32, 1.0, 1.1));
try testing.expect(deepEqual(f64, 3.14159, 3.14159));
try testing.expect(!deepEqual(f64, 3.14159, 3.14158));
}
test "deepEqual enum" {
const Color = enum { red, green, blue };
try testing.expect(deepEqual(Color, .red, .red));
try testing.expect(deepEqual(Color, .blue, .blue));
try testing.expect(!deepEqual(Color, .red, .green));
try testing.expect(!deepEqual(Color, .green, .blue));
}
test "deepEqual []const u8" {
try testing.expect(deepEqual([]const u8, "hello", "hello"));
try testing.expect(deepEqual([]const u8, "", ""));
try testing.expect(!deepEqual([]const u8, "hello", "world"));
try testing.expect(!deepEqual([]const u8, "hello", "hell"));
try testing.expect(!deepEqual([]const u8, "hello", "hello!"));
}
test "deepEqual [:0]const u8" {
try testing.expect(deepEqual([:0]const u8, "foo", "foo"));
try testing.expect(!deepEqual([:0]const u8, "foo", "bar"));
try testing.expect(!deepEqual([:0]const u8, "foo", "fo"));
}
test "deepEqual []const [:0]const u8" {
const a: []const [:0]const u8 = &.{ "one", "two", "three" };
const b: []const [:0]const u8 = &.{ "one", "two", "three" };
const c: []const [:0]const u8 = &.{ "one", "two" };
const d: []const [:0]const u8 = &.{ "one", "two", "four" };
const e: []const [:0]const u8 = &.{};
try testing.expect(deepEqual([]const [:0]const u8, a, b));
try testing.expect(!deepEqual([]const [:0]const u8, a, c));
try testing.expect(!deepEqual([]const [:0]const u8, a, d));
try testing.expect(deepEqual([]const [:0]const u8, e, e));
try testing.expect(!deepEqual([]const [:0]const u8, a, e));
}
test "deepEqual optional" {
try testing.expect(deepEqual(?i32, null, null));
try testing.expect(deepEqual(?i32, 42, 42));
try testing.expect(!deepEqual(?i32, null, 42));
try testing.expect(!deepEqual(?i32, 42, null));
try testing.expect(!deepEqual(?i32, 42, 43));
}
test "deepEqual optional nested" {
const Nested = struct { x: i32, y: i32 };
try testing.expect(deepEqual(?Nested, null, null));
try testing.expect(deepEqual(?Nested, .{ .x = 1, .y = 2 }, .{ .x = 1, .y = 2 }));
try testing.expect(!deepEqual(?Nested, .{ .x = 1, .y = 2 }, .{ .x = 1, .y = 3 }));
try testing.expect(!deepEqual(?Nested, .{ .x = 1, .y = 2 }, null));
}
test "deepEqual array" {
try testing.expect(deepEqual([3]i32, .{ 1, 2, 3 }, .{ 1, 2, 3 }));
try testing.expect(!deepEqual([3]i32, .{ 1, 2, 3 }, .{ 1, 2, 4 }));
try testing.expect(!deepEqual([3]i32, .{ 1, 2, 3 }, .{ 0, 2, 3 }));
try testing.expect(deepEqual([0]i32, .{}, .{}));
}
test "deepEqual nested array" {
const a = [2][2]i32{ .{ 1, 2 }, .{ 3, 4 } };
const b = [2][2]i32{ .{ 1, 2 }, .{ 3, 4 } };
const c = [2][2]i32{ .{ 1, 2 }, .{ 3, 5 } };
try testing.expect(deepEqual([2][2]i32, a, b));
try testing.expect(!deepEqual([2][2]i32, a, c));
}
test "deepEqual struct" {
const Point = struct { x: i32, y: i32 };
try testing.expect(deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 10, .y = 20 }));
try testing.expect(!deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 10, .y = 21 }));
try testing.expect(!deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 11, .y = 20 }));
}
test "deepEqual struct nested" {
const Inner = struct { value: i32 };
const Outer = struct { a: Inner, b: Inner };
const x = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 2 } };
const y = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 2 } };
const z = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 3 } };
try testing.expect(deepEqual(Outer, x, y));
try testing.expect(!deepEqual(Outer, x, z));
}
test "deepEqual struct with equal decl" {
const Custom = struct {
value: i32,
pub fn equal(self: @This(), other: @This()) bool {
return @mod(self.value, 10) == @mod(other.value, 10);
}
};
try testing.expect(deepEqual(Custom, .{ .value = 5 }, .{ .value = 15 }));
try testing.expect(deepEqual(Custom, .{ .value = 100 }, .{ .value = 0 }));
try testing.expect(!deepEqual(Custom, .{ .value = 5 }, .{ .value = 6 }));
}
test "deepEqual union" {
const Value = union(enum) {
int: i32,
float: f32,
none,
};
try testing.expect(deepEqual(Value, .{ .int = 42 }, .{ .int = 42 }));
try testing.expect(!deepEqual(Value, .{ .int = 42 }, .{ .int = 43 }));
try testing.expect(!deepEqual(Value, .{ .int = 42 }, .{ .float = 42.0 }));
try testing.expect(deepEqual(Value, .none, .none));
try testing.expect(!deepEqual(Value, .none, .{ .int = 0 }));
}
test "deepEqual union with equal decl" {
const Value = union(enum) {
num: i32,
str: []const u8,
pub fn equal(self: @This(), other: @This()) bool {
return switch (self) {
.num => |n| switch (other) {
.num => |m| @mod(n, 10) == @mod(m, 10),
else => false,
},
.str => |s| switch (other) {
.str => |t| s.len == t.len,
else => false,
},
};
}
};
try testing.expect(deepEqual(Value, .{ .num = 5 }, .{ .num = 25 }));
try testing.expect(!deepEqual(Value, .{ .num = 5 }, .{ .num = 6 }));
try testing.expect(deepEqual(Value, .{ .str = "abc" }, .{ .str = "xyz" }));
try testing.expect(!deepEqual(Value, .{ .str = "abc" }, .{ .str = "ab" }));
}
test "deepEqual array of structs" {
const Item = struct { id: i32, name: []const u8 };
const a = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "two" } };
const b = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "two" } };
const c = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "TWO" } };
try testing.expect(deepEqual([2]Item, a, b));
try testing.expect(!deepEqual([2]Item, a, c));
}
test "deepEqual struct with optional field" {
const Config = struct { name: []const u8, port: ?u16 };
try testing.expect(deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = 8080 }));
try testing.expect(deepEqual(Config, .{ .name = "app", .port = null }, .{ .name = "app", .port = null }));
try testing.expect(!deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = null }));
try testing.expect(!deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = 8081 }));
}
test "deepEqual struct with array field" {
const Data = struct { values: [3]i32 };
try testing.expect(deepEqual(Data, .{ .values = .{ 1, 2, 3 } }, .{ .values = .{ 1, 2, 3 } }));
try testing.expect(!deepEqual(Data, .{ .values = .{ 1, 2, 3 } }, .{ .values = .{ 1, 2, 4 } }));
}

View File

@@ -19,4 +19,6 @@ pub const SplitTree = split_tree.SplitTree;
test {
@import("std").testing.refAllDecls(@This());
_ = @import("comparison.zig");
}

View File

@@ -158,9 +158,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
);
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
const options = @field(Action, field.name).options();
// assumes options will never be created with only <_name> members
if (@typeInfo(options).@"struct".fields.len == 0) continue;
@@ -194,9 +191,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
);
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
const options = @field(Action, field.name).options();
if (@typeInfo(options).@"struct".fields.len == 0) continue;
@@ -272,9 +266,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
);
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
try writer.writeAll(pad1 ++ "topLevel+=\" +" ++ field.name ++ "\"\n");
}
@@ -296,7 +287,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\ else prev="${COMP_WORDS[COMP_CWORD-1]}"
\\ fi
\\
\\ # current completion is double quoted add a space so the curor progresses
\\ # current completion is double quoted add a space so the cursor progresses
\\ if [[ "$2" == \"*\" ]]; then
\\ COMPREPLY=( "$cur " );
\\ return;

View File

@@ -28,8 +28,6 @@ fn writeCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll("set -l commands \"");
var count: usize = 0;
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
if (count > 0) try writer.writeAll(" ");
try writer.writeAll("+");
try writer.writeAll(field.name);
@@ -98,8 +96,6 @@ fn writeCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll("complete -c ghostty -n \"string match -q -- '+*' (commandline -pt)\" -f -a \"");
var count: usize = 0;
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
if (count > 0) try writer.writeAll(" ");
try writer.writeAll("+");
try writer.writeAll(field.name);

View File

@@ -10,7 +10,7 @@ pub const ftdetect =
\\"
\\" THIS FILE IS AUTO-GENERATED
\\
\\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty
\\au BufRead,BufNewFile */ghostty/config,*/*.ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty
\\
;
pub const ftplugin =

View File

@@ -139,9 +139,6 @@ fn writeZshCompletions(writer: *std.Io.Writer) !void {
var count: usize = 0;
const padding = " ";
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
try writer.writeAll(padding ++ "'+");
try writer.writeAll(field.name);
try writer.writeAll("'\n");
@@ -168,9 +165,6 @@ fn writeZshCompletions(writer: *std.Io.Writer) !void {
{
const padding = " ";
for (@typeInfo(Action).@"enum".fields) |field| {
if (std.mem.eql(u8, "help", field.name)) continue;
if (std.mem.eql(u8, "version", field.name)) continue;
const options = @field(Action, field.name).options();
// assumes options will never be created with only <_name> members
if (@typeInfo(options).@"struct".fields.len == 0) continue;

View File

@@ -47,3 +47,9 @@ pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf");
/// Terminus TTF is a scalable font with bitmap glyphs at various sizes.
pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf");
/// Spleen is a monospaced bitmap font available in multiple formats.
/// Used for testing bitmap font support across different file formats.
pub const spleen_bdf = @embedFile("res/spleen-8x16.bdf");
pub const spleen_pcf = @embedFile("res/spleen-8x16.pcf");
pub const spleen_otb = @embedFile("res/spleen-8x16.otb");

View File

@@ -1284,3 +1284,153 @@ test "bitmap glyph" {
}
}
}
// Expected pixel pattern for Spleen 8x16 'A' (glyph index from char 'A')
// Derived from BDF BITMAP data: 00,00,7C,C6,C6,C6,FE,C6,C6,C6,C6,C6,00,00,00,00
const spleen_A =
\\........
\\........
\\.#####..
\\##...##.
\\##...##.
\\##...##.
\\#######.
\\##...##.
\\##...##.
\\##...##.
\\##...##.
\\##...##.
\\........
\\........
\\........
\\........
;
// Including the newline
const spleen_A_pitch = 9;
// Test parameters for bitmap font tests
const spleen_test_point_size = 12;
const spleen_test_dpi = 96;
test "bitmap glyph BDF" {
const alloc = testing.allocator;
const testFont = font.embedded.spleen_bdf;
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
// Spleen 8x16 is a pure bitmap font at 16px height
var ft_font = try Face.init(lib, testFont, .{ .size = .{
.points = spleen_test_point_size,
.xdpi = spleen_test_dpi,
.ydpi = spleen_test_dpi,
} });
defer ft_font.deinit();
// Get glyph index for 'A'
const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound;
const glyph = try ft_font.renderGlyph(
alloc,
&atlas,
glyph_index,
.{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) },
);
// Verify dimensions match Spleen 8x16
try testing.expectEqual(8, glyph.width);
try testing.expectEqual(16, glyph.height);
// Verify pixel-perfect rendering
for (0..glyph.height) |y| {
for (0..glyph.width) |x| {
const pixel = spleen_A[y * spleen_A_pitch + x];
try testing.expectEqual(
@as(u8, if (pixel == '#') 255 else 0),
atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)],
);
}
}
}
test "bitmap glyph PCF" {
const alloc = testing.allocator;
const testFont = font.embedded.spleen_pcf;
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
var ft_font = try Face.init(lib, testFont, .{ .size = .{
.points = spleen_test_point_size,
.xdpi = spleen_test_dpi,
.ydpi = spleen_test_dpi,
} });
defer ft_font.deinit();
const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound;
const glyph = try ft_font.renderGlyph(
alloc,
&atlas,
glyph_index,
.{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) },
);
try testing.expectEqual(8, glyph.width);
try testing.expectEqual(16, glyph.height);
for (0..glyph.height) |y| {
for (0..glyph.width) |x| {
const pixel = spleen_A[y * spleen_A_pitch + x];
try testing.expectEqual(
@as(u8, if (pixel == '#') 255 else 0),
atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)],
);
}
}
}
test "bitmap glyph OTB" {
const alloc = testing.allocator;
const testFont = font.embedded.spleen_otb;
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
var ft_font = try Face.init(lib, testFont, .{ .size = .{
.points = spleen_test_point_size,
.xdpi = spleen_test_dpi,
.ydpi = spleen_test_dpi,
} });
defer ft_font.deinit();
const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound;
const glyph = try ft_font.renderGlyph(
alloc,
&atlas,
glyph_index,
.{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) },
);
try testing.expectEqual(8, glyph.width);
try testing.expectEqual(16, glyph.height);
for (0..glyph.height) |y| {
for (0..glyph.width) |x| {
const pixel = spleen_A[y * spleen_A_pitch + x];
try testing.expectEqual(
@as(u8, if (pixel == '#') 255 else 0),
atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)],
);
}
}
}

24
src/font/res/BSD-2-Clause.txt vendored Normal file
View File

@@ -0,0 +1,24 @@
Copyright (c) 2018-2024, Frederic Cambus
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,6 +1,6 @@
# Fonts and Licenses
This project uses several fonts which fall under the SIL Open Font License (OFL-1.1) and MIT License:
This project uses several fonts which fall under the SIL Open Font License (OFL-1.1), MIT License, and BSD 2-Clause License:
- Code New Roman (OFL-1.1)
- [© 2014 Sam Radian. All Rights Reserved.](https://github.com/chrissimpkins/codeface/blob/master/fonts/code-new-roman/license.txt)
@@ -28,8 +28,12 @@ This project uses several fonts which fall under the SIL Open Font License (OFL-
- Terminus TTF (OFL-1.1)
- [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/)
- [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/)
- Spleen (BSD 2-Clause)
- [Copyright (c) 2018-2024, Frederic Cambus](https://github.com/fcambus/spleen)
A full copy of the OFL license can be found at [OFL.txt](./OFL.txt).
An accompanying FAQ is also available at <https://openfontlicense.org/>.
A full copy of the MIT license can be found at [MIT.txt](./MIT.txt).
A full copy of the BSD 2-Clause license can be found at [BSD-2-Clause.txt](./BSD-2-Clause.txt).

22328
src/font/res/spleen-8x16.bdf vendored Normal file

File diff suppressed because it is too large Load Diff

BIN
src/font/res/spleen-8x16.otb vendored Normal file

Binary file not shown.

BIN
src/font/res/spleen-8x16.pcf vendored Normal file

Binary file not shown.

View File

@@ -103,15 +103,9 @@ pub const Shaper = struct {
}
};
const RunOffset = struct {
x: f64 = 0,
y: f64 = 0,
};
const CellOffset = struct {
cluster: u32 = 0,
x: f64 = 0,
y: f64 = 0,
};
/// Create a CoreFoundation Dictionary suitable for
@@ -388,15 +382,15 @@ pub const Shaper = struct {
const line = typesetter.createLine(.{ .location = 0, .length = 0 });
self.cf_release_pool.appendAssumeCapacity(line);
// This keeps track of the current offsets within a run.
var run_offset: RunOffset = .{};
// This keeps track of the current x offset (sum of advance.width)
var run_offset_x: f64 = 0.0;
// This keeps track of the current offsets within a cell.
// This keeps track of the cell starting x and cluster.
var cell_offset: CellOffset = .{};
// For debugging positions, turn this on:
//var start_index: usize = 0;
//var end_index: usize = 0;
//var run_offset_y: f64 = 0.0;
//var cell_offset_y: f64 = 0.0;
// Clear our cell buf and make sure we have enough room for the whole
// line of glyphs, so that we can just assume capacity when appending
@@ -450,39 +444,31 @@ pub const Shaper = struct {
cell_offset = .{
.cluster = cluster,
.x = run_offset.x,
.y = run_offset.y,
.x = run_offset_x,
};
// For debugging positions, turn this on:
// start_index = index;
// end_index = index;
//} else {
// if (index < start_index) {
// start_index = index;
// }
// if (index > end_index) {
// end_index = index;
// }
//cell_offset_y = run_offset_y;
}
// For debugging positions, turn this on:
//try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index);
//try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index);
const x_offset = position.x - cell_offset.x;
const y_offset = position.y - cell_offset.y;
self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(cluster),
.x_offset = @intFromFloat(@round(x_offset)),
.y_offset = @intFromFloat(@round(y_offset)),
.y_offset = @intFromFloat(@round(position.y)),
.glyph_index = glyph,
});
// Add our advances to keep track of our run offsets.
// Advances apply to the NEXT cell.
run_offset.x += advance.width;
run_offset.y += advance.height;
run_offset_x += advance.width;
// For debugging positions, turn this on:
//run_offset_y += advance.height;
}
}
@@ -655,57 +641,81 @@ pub const Shaper = struct {
fn debugPositions(
self: *Shaper,
alloc: Allocator,
run_offset: RunOffset,
run_offset_x: f64,
run_offset_y: f64,
cell_offset: CellOffset,
cell_offset_y: f64,
position: macos.graphics.Point,
start_index: usize,
end_index: usize,
index: usize,
) !void {
const state = &self.run_state;
const x_offset = position.x - cell_offset.x;
const y_offset = position.y - cell_offset.y;
const advance_x_offset = run_offset.x - cell_offset.x;
const advance_y_offset = run_offset.y - cell_offset.y;
const advance_x_offset = run_offset_x - cell_offset.x;
const advance_y_offset = run_offset_y - cell_offset_y;
const x_offset_diff = x_offset - advance_x_offset;
const y_offset_diff = y_offset - advance_y_offset;
const y_offset_diff = position.y - advance_y_offset;
const positions_differ = @abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001;
const old_offset_y = position.y - cell_offset_y;
const position_y_differs = @abs(cell_offset_y) > 0.0001;
if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) {
if (positions_differ or position_y_differs) {
var allocating = std.Io.Writer.Allocating.init(alloc);
const writer = &allocating.writer;
const codepoints = state.codepoints.items[start_index .. end_index + 1];
const codepoints = state.codepoints.items;
const current_cp = state.codepoints.items[index].codepoint;
var last_cluster: ?u32 = null;
for (codepoints) |cp| {
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
try writer.print("\\u{{{x}}}", .{cp.codepoint});
if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and
cp.codepoint != 0 // Skip surrogate pair padding
) {
if (last_cluster) |last| {
if (cp.cluster != last) {
try writer.writeAll(" ");
}
}
if (cp.cluster == cell_offset.cluster and cp.codepoint == current_cp) {
try writer.writeAll("");
}
try writer.print("\\u{{{x}}}", .{cp.codepoint});
last_cluster = cp.cluster;
}
}
try writer.writeAll("");
for (codepoints) |cp| {
if (cp.codepoint == 0) continue; // Skip surrogate pair padding
try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))});
if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and
cp.codepoint != 0 // Skip surrogate pair padding
) {
try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))});
}
}
const formatted_cps = try allocating.toOwnedSlice();
// Note that the codepoints from `start_index .. end_index + 1`
// might not include all the codepoints being shaped. Sometimes a
// codepoint gets represented in a glyph with a later codepoint
// such that the index for the former codepoint is skipped and just
// the index for the latter codepoint is used. Additionally, this
// gets called as we iterate through the glyphs, so it won't
// include the codepoints that come later that might be affecting
// positions for the current glyph. Usually though, for that case
// the positions of the later glyphs will also be affected and show
// up in the logs.
log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{
cell_offset.cluster,
x_offset,
y_offset,
advance_x_offset,
advance_y_offset,
x_offset_diff,
y_offset_diff,
state.codepoints.items[index].codepoint,
formatted_cps,
});
if (positions_differ) {
log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) cps = {s}", .{
cell_offset.cluster,
x_offset,
position.y,
advance_x_offset,
advance_y_offset,
x_offset_diff,
y_offset_diff,
formatted_cps,
});
}
if (position_y_differs) {
log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} cps = {s}", .{
cell_offset.cluster,
x_offset,
position.y,
run_offset_x,
run_offset_y,
cell_offset.x,
cell_offset_y,
old_offset_y,
formatted_cps,
});
}
}
}
};
@@ -1522,6 +1532,66 @@ test "shape Tai Tham vowels (position differs from advance)" {
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Tai Tham letters (position.y differs from advance)" {
const testing = std.testing;
const alloc = testing.allocator;
// We need a font that supports Tai Tham for this to work, if we can't find
// Noto Sans Tai Tham, which is a system font on macOS, we just skip the
// test.
var testdata = testShaperWithDiscoveredFont(
alloc,
"Noto Sans Tai Tham",
) catch return error.SkipZigTest;
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
// First grapheme cluster:
buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA
buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT
// Second grapheme cluster, combining with the first in a ligature:
buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA
buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U
// Make a screen with some data
var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 });
defer t.deinit(alloc);
// Enable grapheme clustering
t.modes.set(.grapheme_cluster, true);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 3), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
try testing.expectEqual(@as(u16, 1), cells[2].x); // U from second grapheme
// The U glyph renders at a y below zero
try testing.expectEqual(@as(i16, -3), cells[2].y_offset);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape box glyphs" {
const testing = std.testing;
const alloc = testing.allocator;

Some files were not shown because too many files have changed in this diff Show More