mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Merge remote-tracking branch 'upstream/main' into harfbuzz-positions
This commit is contained in:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -4,11 +4,11 @@ build.zig.zon.json linguist-generated=true
|
||||
vendor/** linguist-vendored
|
||||
website/** linguist-documentation
|
||||
pkg/breakpad/vendor/** linguist-vendored
|
||||
pkg/cimgui/vendor/** linguist-vendored
|
||||
pkg/glfw/wayland-headers/** linguist-vendored
|
||||
pkg/libintl/config.h linguist-generated=true
|
||||
pkg/libintl/libintl.h linguist-generated=true
|
||||
pkg/simdutf/vendor/** linguist-vendored
|
||||
src/font/nerd_font_attributes.zig linguist-generated=true
|
||||
src/font/nerd_font_codepoint_tables.py linguist-generated=true
|
||||
src/font/res/** linguist-vendored
|
||||
src/terminal/res/** linguist-vendored
|
||||
|
||||
2
.github/workflows/publish-tag.yml
vendored
2
.github/workflows/publish-tag.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
mkdir blob
|
||||
mv appcast.xml blob/appcast.xml
|
||||
- name: Upload Appcast to R2
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1
|
||||
uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4
|
||||
with:
|
||||
r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }}
|
||||
r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }}
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -217,6 +217,7 @@ jobs:
|
||||
x86_64-macos,
|
||||
aarch64-linux,
|
||||
x86_64-linux,
|
||||
x86_64-linux-musl,
|
||||
x86_64-windows,
|
||||
wasm32-freestanding,
|
||||
]
|
||||
|
||||
@@ -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
23
build.zig.zon.json
generated
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": {
|
||||
"name": "bindings",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM="
|
||||
},
|
||||
"N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ": {
|
||||
"name": "breakpad",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -44,15 +49,15 @@
|
||||
"url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz",
|
||||
"hash": "sha256-h9T4iT704I8iSXNgj/6/lCaKgTgLp5wS6IQZaMgKohI="
|
||||
},
|
||||
"N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3": {
|
||||
"N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI": {
|
||||
"name": "imgui",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
|
||||
},
|
||||
"N-V-__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
26
build.zig.zon.nix
generated
@@ -82,6 +82,14 @@
|
||||
fetcher.${proto};
|
||||
in
|
||||
linkFarm name [
|
||||
{
|
||||
name = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr";
|
||||
path = fetchZigArtifact {
|
||||
name = "bindings";
|
||||
url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ";
|
||||
path = fetchZigArtifact {
|
||||
@@ -155,19 +163,19 @@ in
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3";
|
||||
name = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI";
|
||||
path = fetchZigArtifact {
|
||||
name = "imgui";
|
||||
url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz";
|
||||
hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=";
|
||||
url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz";
|
||||
hash = "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "N-V-__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
7
build.zig.zon.txt
generated
@@ -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
|
||||
|
||||
100
dist/linux/ghostty_nautilus.py
vendored
100
dist/linux/ghostty_nautilus.py
vendored
@@ -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
23
flake.lock
generated
@@ -41,27 +41,26 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755776884,
|
||||
"narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=",
|
||||
"lastModified": 1768068402,
|
||||
"narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86",
|
||||
"rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "release-25.05",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763191728,
|
||||
"narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=",
|
||||
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -126,17 +125,17 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1758405547,
|
||||
"narHash": "sha256-WgaDgvIZMPvlZcZrpPMjkaalTBnGF2lTG+62znXctWM=",
|
||||
"lastModified": 1768231828,
|
||||
"narHash": "sha256-wL/8Iij4T2OLkhHcc4NieOjf7YeJffaUYbCiCqKv/+0=",
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jcollie",
|
||||
"repo": "zon2nix",
|
||||
"rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245",
|
||||
"rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
};
|
||||
|
||||
zon2nix = {
|
||||
url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245";
|
||||
url = "github:jcollie/zon2nix?rev=c28e93f3ba133d4c1b1d65224e2eebede61fd071";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager?ref=release-25.05";
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs = {
|
||||
nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
@@ -117,7 +117,6 @@
|
||||
wayland-gnome = runVM ./nix/vm/wayland-gnome.nix;
|
||||
wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix;
|
||||
x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix;
|
||||
x11-gnome = runVM ./nix/vm/x11-gnome.nix;
|
||||
x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix;
|
||||
x11-xfce = runVM ./nix/vm/x11-xfce.nix;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
|
||||
"sha256": "8bfec500e00926f679853ee23d67cc392d3c3181733ca4704738651d3f70baa3"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz",
|
||||
@@ -55,15 +61,15 @@
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
"sha256": "a05fd01e04cf11ab781e28387c621d2e420f1e6044c8e27a25e603ea99ef7860"
|
||||
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
"dest": "vendor/p/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
|
||||
"sha256": "c816c20e8c75f3e15ae867350e79925502d1a6a85938bb1a73b8927e5f31f9cb"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */;
|
||||
};
|
||||
|
||||
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
23
macos/Sources/App/macOS/AppDelegate+Ghostty.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: Ghostty Delegate
|
||||
|
||||
/// This implements the Ghostty app delegate protocol which is used by the Ghostty
|
||||
/// APIs for app-global information.
|
||||
extension AppDelegate: Ghostty.Delegate {
|
||||
func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? {
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else {
|
||||
continue
|
||||
}
|
||||
|
||||
for surface in controller.surfaceTree {
|
||||
if surface.id == id {
|
||||
return surface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class AppDelegate: NSObject,
|
||||
@IBOutlet private var menuSelectAll: NSMenuItem?
|
||||
@IBOutlet private var menuFindParent: NSMenuItem?
|
||||
@IBOutlet private var menuFind: NSMenuItem?
|
||||
@IBOutlet private var menuSelectionForFind: NSMenuItem?
|
||||
@IBOutlet private var menuScrollToSelection: NSMenuItem?
|
||||
@IBOutlet private var menuFindNext: NSMenuItem?
|
||||
@IBOutlet private var menuFindPrevious: NSMenuItem?
|
||||
@IBOutlet private var menuHideFindBar: NSMenuItem?
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
@@ -58,6 +58,7 @@
|
||||
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
|
||||
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
|
||||
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
|
||||
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
|
||||
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
|
||||
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
|
||||
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
|
||||
@@ -281,6 +282,19 @@
|
||||
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="2N8-Xz-RVc"/>
|
||||
<menuItem title="Use Selection for Find" id="TDN-42-Bu7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Jump to Selection" id="1rN-4k-Dz3">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="scrollToSelection:" target="-1" id="5gS-8h-Xm2"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
</items>
|
||||
</menu>
|
||||
</menuItem>
|
||||
|
||||
@@ -44,10 +44,7 @@ struct AboutView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
ghosttyIconImage()
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 128)
|
||||
CyclingIconView()
|
||||
|
||||
VStack(alignment: .center, spacing: 32) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
|
||||
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
62
macos/Sources/Features/About/CyclingIconView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
|
||||
/// A view that cycles through Ghostty's official icon variants.
|
||||
struct CyclingIconView: View {
|
||||
@State private var currentIcon: Ghostty.MacOSIcon = .official
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
private let icons: [Ghostty.MacOSIcon] = [
|
||||
.official,
|
||||
.blueprint,
|
||||
.chalkboard,
|
||||
.microchip,
|
||||
.glass,
|
||||
.holographic,
|
||||
.paper,
|
||||
.retro,
|
||||
.xray,
|
||||
]
|
||||
private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
iconView(for: currentIcon)
|
||||
.id(currentIcon)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.5), value: currentIcon)
|
||||
.frame(height: 128)
|
||||
.onReceive(timerPublisher.autoconnect()) { _ in
|
||||
if !isHovering {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
}
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
.onTapGesture {
|
||||
advanceToNextIcon()
|
||||
}
|
||||
.help("macos-icon = \(currentIcon.rawValue)")
|
||||
.accessibilityLabel("Ghostty Application Icon")
|
||||
.accessibilityHint("Click to cycle through icon variants")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iconView(for icon: Ghostty.MacOSIcon) -> some View {
|
||||
let iconImage: Image = switch icon.assetName {
|
||||
case let assetName?: Image(assetName)
|
||||
case nil: ghosttyIconImage()
|
||||
}
|
||||
|
||||
iconImage
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}
|
||||
|
||||
private func advanceToNextIcon() {
|
||||
let currentIndex = icons.firstIndex(of: currentIcon) ?? 0
|
||||
let nextIndex = icons.indexWrapping(after: currentIndex)
|
||||
currentIcon = icons[nextIndex]
|
||||
}
|
||||
}
|
||||
@@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController {
|
||||
}
|
||||
|
||||
// Setup our content
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self
|
||||
))
|
||||
)
|
||||
|
||||
// Clear out our frame at this point, the fixup from above is complete.
|
||||
if let qtWindow = window as? QuickTerminalWindow {
|
||||
@@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
|
||||
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
||||
// Also check if the user has overridden transparency to be fully opaque.
|
||||
if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 {
|
||||
if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) {
|
||||
window.isOpaque = false
|
||||
|
||||
// This is weird, but we don't use ".clear" because this creates a look that
|
||||
@@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController {
|
||||
// Terminal.app more easily.
|
||||
window.backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
if !derivedConfig.backgroundBlur.isGlassStyle {
|
||||
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
||||
}
|
||||
} else {
|
||||
window.isOpaque = true
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
@@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
|
||||
let quickTerminalSize: QuickTerminalSize
|
||||
let backgroundOpacity: Double
|
||||
let backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
|
||||
init() {
|
||||
self.quickTerminalScreen = .main
|
||||
@@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = .move
|
||||
self.quickTerminalSize = QuickTerminalSize()
|
||||
self.backgroundOpacity = 1.0
|
||||
self.backgroundBlur = .disabled
|
||||
}
|
||||
|
||||
init(_ config: Ghostty.Config) {
|
||||
@@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
|
||||
self.quickTerminalSize = config.quickTerminalSize
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,10 +121,10 @@ extension SplitTree {
|
||||
|
||||
/// Insert a new view at the given view point by creating a split in the given direction.
|
||||
/// This will always reset the zoomed state of the tree.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
return .init(
|
||||
root: try root.insert(view: view, at: at, direction: direction),
|
||||
root: try root.inserting(view: view, at: at, direction: direction),
|
||||
zoomed: nil)
|
||||
}
|
||||
/// Find a node containing a view with the specified ID.
|
||||
@@ -137,7 +137,7 @@ extension SplitTree {
|
||||
|
||||
/// Remove a node from the tree. If the node being removed is part of a split,
|
||||
/// the sibling node takes the place of the parent split.
|
||||
func remove(_ target: Node) -> Self {
|
||||
func removing(_ target: Node) -> Self {
|
||||
guard let root else { return self }
|
||||
|
||||
// If we're removing the root itself, return an empty tree
|
||||
@@ -155,7 +155,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
/// Replace a node in the tree with a new node.
|
||||
func replace(node: Node, with newNode: Node) throws -> Self {
|
||||
func replacing(node: Node, with newNode: Node) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
// Get the path to the node we want to replace
|
||||
@@ -164,7 +164,7 @@ extension SplitTree {
|
||||
}
|
||||
|
||||
// Replace the node
|
||||
let newRoot = try root.replaceNode(at: path, with: newNode)
|
||||
let newRoot = try root.replacingNode(at: path, with: newNode)
|
||||
|
||||
// Update zoomed if it was the replaced node
|
||||
let newZoomed = (zoomed == node) ? newNode : zoomed
|
||||
@@ -232,7 +232,7 @@ extension SplitTree {
|
||||
|
||||
/// Equalize all splits in the tree so that each split's ratio is based on the
|
||||
/// relative weight (number of leaves) of its children.
|
||||
func equalize() -> Self {
|
||||
func equalized() -> Self {
|
||||
guard let root else { return self }
|
||||
let newRoot = root.equalize()
|
||||
return .init(root: newRoot, zoomed: zoomed)
|
||||
@@ -255,7 +255,7 @@ extension SplitTree {
|
||||
/// - bounds: The bounds used to construct the spatial tree representation
|
||||
/// - Returns: A new SplitTree with the adjusted split ratios
|
||||
/// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
|
||||
func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
|
||||
func resizing(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
|
||||
guard let root else { throw SplitError.viewNotFound }
|
||||
|
||||
// Find the path to the target node
|
||||
@@ -327,7 +327,7 @@ extension SplitTree {
|
||||
)
|
||||
|
||||
// Replace the split node with the new one
|
||||
let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
|
||||
let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit))
|
||||
return .init(root: newRoot, zoomed: nil)
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ extension SplitTree.Node {
|
||||
///
|
||||
/// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
|
||||
/// maybe throw instead but at the moment we just do nothing.
|
||||
func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
|
||||
// Get the path to our insertion point. If it doesn't exist we do
|
||||
// nothing.
|
||||
guard let path = path(to: .leaf(view: at)) else {
|
||||
@@ -544,11 +544,11 @@ extension SplitTree.Node {
|
||||
))
|
||||
|
||||
// Replace the node at the path with the new split
|
||||
return try replaceNode(at: path, with: newSplit)
|
||||
return try replacingNode(at: path, with: newSplit)
|
||||
}
|
||||
|
||||
/// Helper function to replace a node at the given path from the root
|
||||
func replaceNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
func replacingNode(at path: Path, with newNode: Self) throws -> Self {
|
||||
// If path is empty, replace the root
|
||||
if path.isEmpty {
|
||||
return newNode
|
||||
@@ -635,7 +635,7 @@ extension SplitTree.Node {
|
||||
/// Resize a split node to the specified ratio.
|
||||
/// For leaf nodes, this returns the node unchanged.
|
||||
/// For split nodes, this creates a new split with the updated ratio.
|
||||
func resize(to ratio: Double) -> Self {
|
||||
func resizing(to ratio: Double) -> Self {
|
||||
switch self {
|
||||
case .leaf:
|
||||
// Leaf nodes don't have a ratio to resize
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single operation within the split tree.
|
||||
///
|
||||
/// Rather than binding the split tree (which is immutable), any mutable operations are
|
||||
/// exposed via this enum to the embedder to handle.
|
||||
enum TerminalSplitOperation {
|
||||
case resize(Resize)
|
||||
case drop(Drop)
|
||||
|
||||
struct Resize {
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
let ratio: Double
|
||||
}
|
||||
|
||||
struct Drop {
|
||||
/// The surface being dragged.
|
||||
let payload: Ghostty.SurfaceView
|
||||
|
||||
/// The surface it was dragged onto
|
||||
let destination: Ghostty.SurfaceView
|
||||
|
||||
/// The zone it was dropped to determine how to split the destination.
|
||||
let zone: TerminalSplitDropZone
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitTreeView: View {
|
||||
let tree: SplitTree<Ghostty.SurfaceView>
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
if let node = tree.zoomed ?? tree.root {
|
||||
TerminalSplitSubtreeView(
|
||||
node: node,
|
||||
isRoot: node == tree.root,
|
||||
onResize: onResize)
|
||||
action: action)
|
||||
// This is necessary because we can't rely on SwiftUI's implicit
|
||||
// structural identity to detect changes to this view. Due to
|
||||
// the tree structure of splits it could result in bad behaviors.
|
||||
@@ -19,21 +44,17 @@ struct TerminalSplitTreeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalSplitSubtreeView: View {
|
||||
fileprivate struct TerminalSplitSubtreeView: View {
|
||||
@EnvironmentObject var ghostty: Ghostty.App
|
||||
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
var isRoot: Bool = false
|
||||
let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
var body: some View {
|
||||
switch (node) {
|
||||
case .leaf(let leafView):
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: leafView,
|
||||
isSplit: !isRoot)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
|
||||
|
||||
case .split(let split):
|
||||
let splitViewDirection: SplitViewDirection = switch (split.direction) {
|
||||
@@ -46,15 +67,15 @@ struct TerminalSplitSubtreeView: View {
|
||||
.init(get: {
|
||||
CGFloat(split.ratio)
|
||||
}, set: {
|
||||
onResize(node, $0)
|
||||
action(.resize(.init(node: node, ratio: $0)))
|
||||
}),
|
||||
dividerColor: ghostty.config.splitDividerColor,
|
||||
resizeIncrements: .init(width: 1, height: 1),
|
||||
left: {
|
||||
TerminalSplitSubtreeView(node: split.left, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.left, action: action)
|
||||
},
|
||||
right: {
|
||||
TerminalSplitSubtreeView(node: split.right, onResize: onResize)
|
||||
TerminalSplitSubtreeView(node: split.right, action: action)
|
||||
},
|
||||
onEqualize: {
|
||||
guard let surface = node.leftmostLeaf().surface else { return }
|
||||
@@ -64,3 +85,173 @@ struct TerminalSplitSubtreeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct TerminalSplitLeaf: View {
|
||||
let surfaceView: Ghostty.SurfaceView
|
||||
let isSplit: Bool
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
@State private var dropState: DropState = .idle
|
||||
@State private var isSelfDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: surfaceView,
|
||||
isSplit: isSplit)
|
||||
.background {
|
||||
// If we're dragging ourself, we hide the entire drop zone. This makes
|
||||
// it so that a released drop animates back to its source properly
|
||||
// so it is a proper invalid drop zone.
|
||||
if !isSelfDragging {
|
||||
Color.clear
|
||||
.onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate(
|
||||
dropState: $dropState,
|
||||
viewSize: geometry.size,
|
||||
destinationSurface: surfaceView,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if !isSelfDragging, case .dropping(let zone) = dropState {
|
||||
zone.overlay(in: geometry)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in
|
||||
isSelfDragging = value == surfaceView.id
|
||||
if isSelfDragging {
|
||||
dropState = .idle
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropState: Equatable {
|
||||
case idle
|
||||
case dropping(TerminalSplitDropZone)
|
||||
}
|
||||
|
||||
private struct SplitDropDelegate: DropDelegate {
|
||||
@Binding var dropState: DropState
|
||||
let viewSize: CGSize
|
||||
let destinationSurface: Ghostty.SurfaceView
|
||||
let action: (TerminalSplitOperation) -> Void
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
info.hasItemsConforming(to: [.ghosttySurfaceId])
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
// For some reason dropUpdated is sent after performDrop is called
|
||||
// and we don't want to reset our drop zone to show it so we have
|
||||
// to guard on the state here.
|
||||
guard case .dropping = dropState else { return DropProposal(operation: .forbidden) }
|
||||
dropState = .dropping(.calculate(at: info.location, in: viewSize))
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
dropState = .idle
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
|
||||
dropState = .idle
|
||||
|
||||
// Load the dropped surface asynchronously using Transferable
|
||||
let providers = info.itemProviders(for: [.ghosttySurfaceId])
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
// Capture action before the async closure
|
||||
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
|
||||
switch result {
|
||||
case .success(let sourceSurface):
|
||||
DispatchQueue.main.async {
|
||||
// Don't allow dropping on self
|
||||
guard let destinationSurface else { return }
|
||||
guard sourceSurface !== destinationSurface else { return }
|
||||
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
|
||||
}
|
||||
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalSplitDropZone: String, Equatable {
|
||||
case top
|
||||
case bottom
|
||||
case left
|
||||
case right
|
||||
|
||||
/// Determines which drop zone the cursor is in based on proximity to edges.
|
||||
///
|
||||
/// Divides the view into four triangular regions by drawing diagonals from
|
||||
/// corner to corner. The drop zone is determined by which edge the cursor
|
||||
/// is closest to, creating natural triangular hit regions for each side.
|
||||
static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone {
|
||||
let relX = point.x / size.width
|
||||
let relY = point.y / size.height
|
||||
|
||||
let distToLeft = relX
|
||||
let distToRight = 1 - relX
|
||||
let distToTop = relY
|
||||
let distToBottom = 1 - relY
|
||||
|
||||
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
|
||||
|
||||
if minDist == distToLeft { return .left }
|
||||
if minDist == distToRight { return .right }
|
||||
if minDist == distToTop { return .top }
|
||||
return .bottom
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func overlay(in geometry: GeometryProxy) -> some View {
|
||||
let overlayColor = Color.accentColor.opacity(0.3)
|
||||
|
||||
switch self {
|
||||
case .top:
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .bottom:
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(height: geometry.size.height / 2)
|
||||
}
|
||||
case .left:
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
Spacer()
|
||||
}
|
||||
case .right:
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(overlayColor)
|
||||
.frame(width: geometry.size.width / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,11 @@ class BaseTerminalController: NSWindowController,
|
||||
selector: #selector(ghosttyDidPresentTerminal(_:)),
|
||||
name: Ghostty.Notification.ghosttyPresentTerminal,
|
||||
object: nil)
|
||||
center.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttySurfaceDragEndedNoTarget(_:)),
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: nil)
|
||||
|
||||
// Listen for local events that we need to know of outside of
|
||||
// single surface handlers.
|
||||
@@ -235,7 +240,7 @@ class BaseTerminalController: NSWindowController,
|
||||
// Do the split
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.insert(
|
||||
newTree = try surfaceTree.inserting(
|
||||
view: newView,
|
||||
at: oldView,
|
||||
direction: direction)
|
||||
@@ -445,14 +450,14 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
surfaceTree.remove(node),
|
||||
surfaceTree.removing(node),
|
||||
moveFocusTo: nextFocus,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Close Terminal"
|
||||
)
|
||||
}
|
||||
|
||||
private func replaceSurfaceTree(
|
||||
func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
@@ -466,33 +471,33 @@ class BaseTerminalController: NSWindowController,
|
||||
Ghostty.moveFocus(to: newView, from: oldView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager {
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
guard let undoManager else { return }
|
||||
if let undoAction {
|
||||
undoManager.setActionName(undoAction)
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: self,
|
||||
expiresAfter: undoExpiration
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.surfaceTree = oldTree
|
||||
if let oldView {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: target,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { target in
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
target.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: target.focusedSurface,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,7 +614,7 @@ class BaseTerminalController: NSWindowController,
|
||||
guard surfaceTree.contains(target) else { return }
|
||||
|
||||
// Equalize the splits
|
||||
surfaceTree = surfaceTree.equalize()
|
||||
surfaceTree = surfaceTree.equalized()
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
|
||||
@@ -699,7 +704,7 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
// Perform the resize using the new SplitTree resize method
|
||||
do {
|
||||
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to resize split: \(error)")
|
||||
}
|
||||
@@ -721,6 +726,42 @@ class BaseTerminalController: NSWindowController,
|
||||
target.highlight()
|
||||
}
|
||||
|
||||
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
// If our tree isn't split, then we never create a new window, because
|
||||
// it is already a single split.
|
||||
guard surfaceTree.isSplit else { return }
|
||||
|
||||
// If we are removing our focused surface then we move it. We need to
|
||||
// keep track of our old one so undo sends focus back to the right place.
|
||||
let oldFocusedSurface = focusedSurface
|
||||
if focusedSurface == target {
|
||||
focusedSurface = findNextFocusTargetAfterClosing(node: targetNode)
|
||||
}
|
||||
|
||||
// Remove the surface from our tree
|
||||
let removedTree = surfaceTree.removing(targetNode)
|
||||
|
||||
// Create a new tree with the dragged surface and open a new window
|
||||
let newTree = SplitTree<Ghostty.SurfaceView>(view: target)
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface)
|
||||
_ = TerminalController.newWindow(
|
||||
ghostty,
|
||||
tree: newTree,
|
||||
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
|
||||
confirmUndo: false)
|
||||
}
|
||||
|
||||
// MARK: Local Events
|
||||
|
||||
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
||||
@@ -793,7 +834,15 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
private func applyTitleToWindow() {
|
||||
guard let window else { return }
|
||||
window.title = titleOverride ?? lastComputedTitle
|
||||
|
||||
if let titleOverride {
|
||||
window.title = computeTitle(
|
||||
title: titleOverride,
|
||||
bell: focusedSurface?.bell ?? false)
|
||||
return
|
||||
}
|
||||
|
||||
window.title = lastComputedTitle
|
||||
}
|
||||
|
||||
func pwdDidChange(to: URL?) {
|
||||
@@ -817,14 +866,101 @@ class BaseTerminalController: NSWindowController,
|
||||
self.window?.contentResizeIncrements = to
|
||||
}
|
||||
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resize(to: newRatio)
|
||||
func performSplitAction(_ action: TerminalSplitOperation) {
|
||||
switch action {
|
||||
case .resize(let resize):
|
||||
splitDidResize(node: resize.node, to: resize.ratio)
|
||||
case .drop(let drop):
|
||||
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
||||
let resizedNode = node.resizing(to: newRatio)
|
||||
do {
|
||||
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
|
||||
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func splitDidDrop(
|
||||
source: Ghostty.SurfaceView,
|
||||
destination: Ghostty.SurfaceView,
|
||||
zone: TerminalSplitDropZone
|
||||
) {
|
||||
// Map drop zone to split direction
|
||||
let direction: SplitTree<Ghostty.SurfaceView>.NewDirection = switch zone {
|
||||
case .top: .up
|
||||
case .bottom: .down
|
||||
case .left: .left
|
||||
case .right: .right
|
||||
}
|
||||
|
||||
// Check if source is in our tree
|
||||
if let sourceNode = surfaceTree.root?.node(view: source) {
|
||||
// Source is in our tree - same window move
|
||||
let treeWithoutSource = surfaceTree.removing(sourceNode)
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface,
|
||||
undoAction: "Move Split")
|
||||
return
|
||||
}
|
||||
|
||||
// Source is not in our tree - search other windows
|
||||
var sourceController: BaseTerminalController?
|
||||
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
|
||||
for window in NSApp.windows {
|
||||
guard let controller = window.windowController as? BaseTerminalController else { continue }
|
||||
guard controller !== self else { continue }
|
||||
if let node = controller.surfaceTree.root?.node(view: source) {
|
||||
sourceController = controller
|
||||
sourceNode = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let sourceController, let sourceNode else {
|
||||
Ghostty.logger.warning("source surface not found in any window during drop")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from source controller's tree and add it to our tree.
|
||||
// We do this first because if there is an error then we can
|
||||
// abort.
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// Treat our undo below as a full group.
|
||||
undoManager?.beginUndoGrouping()
|
||||
undoManager?.setActionName("Move Split")
|
||||
defer {
|
||||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
// Remove the node from the source.
|
||||
sourceController.removeSurfaceNode(sourceNode)
|
||||
|
||||
// Add in the surface to our tree
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: source,
|
||||
moveFocusFrom: focusedSurface)
|
||||
}
|
||||
|
||||
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
|
||||
@@ -1076,6 +1212,15 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
// If when we become key our first responder is the window itself, then we
|
||||
// want to move focus to our focused terminal surface. This works around
|
||||
// various weirdness with moving surfaces around.
|
||||
if let window, window.firstResponder == window, let focusedSurface {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
// Becoming/losing key means we have to notify our surface(s) that we have focus
|
||||
// so things like cursors blink, pty events are sent, etc.
|
||||
self.syncFocusToSurfaceTree()
|
||||
@@ -1227,7 +1372,15 @@ class BaseTerminalController: NSWindowController,
|
||||
@IBAction func find(_ sender: Any) {
|
||||
focusedSurface?.find(sender)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func selectionForFind(_ sender: Any) {
|
||||
focusedSurface?.selectionForFind(sender)
|
||||
}
|
||||
|
||||
@IBAction func scrollToSelection(_ sender: Any) {
|
||||
focusedSurface?.scrollToSelection(sender)
|
||||
}
|
||||
|
||||
@IBAction func findNext(_ sender: Any) {
|
||||
focusedSurface?.findNext(sender)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@ import GhosttyKit
|
||||
class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
|
||||
override var windowNibName: NSNib.Name? {
|
||||
let defaultValue = "Terminal"
|
||||
|
||||
|
||||
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
|
||||
let config = appDelegate.ghostty.config
|
||||
|
||||
|
||||
// If we have no window decorations, there's no reason to do anything but
|
||||
// the default titlebar (because there will be no titlebar).
|
||||
if !config.windowDecorations {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
|
||||
let nib = switch config.macosTitlebarStyle {
|
||||
case "native": "Terminal"
|
||||
case "hidden": "TerminalHiddenTitlebar"
|
||||
@@ -34,33 +34,33 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
#endif
|
||||
default: defaultValue
|
||||
}
|
||||
|
||||
|
||||
return nib
|
||||
}
|
||||
|
||||
|
||||
/// This is set to true when we care about frame changes. This is a small optimization since
|
||||
/// this controller registers a listener for ALL frame change notifications and this lets us bail
|
||||
/// early if we don't care.
|
||||
private var tabListenForFrame: Bool = false
|
||||
|
||||
|
||||
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
|
||||
/// changes in the list.
|
||||
private var tabWindowsHash: Int = 0
|
||||
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
private var restorable: Bool = true
|
||||
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig
|
||||
|
||||
|
||||
|
||||
|
||||
/// The notification cancellable for focused surface property changes.
|
||||
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
|
||||
/// This will be set to the initial frame of the window from the xib on load.
|
||||
private var initialFrame: NSRect? = nil
|
||||
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
|
||||
@@ -72,12 +72,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// as the script. We may want to revisit this behavior when we have scrollback
|
||||
// restoration.
|
||||
self.restorable = (base?.command ?? "") == ""
|
||||
|
||||
|
||||
// Setup our initial derived config based on the current app config
|
||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||
|
||||
|
||||
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
||||
|
||||
|
||||
// Setup our notifications for behaviors
|
||||
let center = NotificationCenter.default
|
||||
center.addObserver(
|
||||
@@ -134,36 +134,56 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported for this view")
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Remove all of our notificationcenter subscriptions
|
||||
let center = NotificationCenter.default
|
||||
center.removeObserver(self)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Base Controller Overrides
|
||||
|
||||
|
||||
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
super.surfaceTreeDidChange(from: from, to: to)
|
||||
|
||||
|
||||
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
||||
// we want to invalidate our state.
|
||||
invalidateRestorableState()
|
||||
|
||||
|
||||
// Update our zoom state
|
||||
if let window = window as? TerminalWindow {
|
||||
window.surfaceIsZoomed = to.zoomed != nil
|
||||
}
|
||||
|
||||
|
||||
// If our surface tree is now nil then we close our window.
|
||||
if (to.isEmpty) {
|
||||
self.window?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override func replaceSurfaceTree(
|
||||
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
||||
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
||||
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
||||
undoAction: String? = nil
|
||||
) {
|
||||
// We have a special case if our tree is empty to close our tab immediately.
|
||||
// This makes it so that undo is handled properly.
|
||||
if newTree.isEmpty {
|
||||
closeTabImmediately()
|
||||
return
|
||||
}
|
||||
|
||||
super.replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: oldView,
|
||||
undoAction: undoAction)
|
||||
}
|
||||
|
||||
// MARK: Terminal Creation
|
||||
|
||||
@@ -275,6 +295,72 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
return c
|
||||
}
|
||||
|
||||
/// Create a new window with an existing split tree.
|
||||
/// The window will be sized to match the tree's current view bounds if available.
|
||||
/// - Parameters:
|
||||
/// - ghostty: The Ghostty app instance.
|
||||
/// - tree: The split tree to use for the new window.
|
||||
/// - position: Optional screen position (top-left corner) for the new window.
|
||||
/// If nil, the window will cascade from the last cascade point.
|
||||
static func newWindow(
|
||||
_ ghostty: Ghostty.App,
|
||||
tree: SplitTree<Ghostty.SurfaceView>,
|
||||
position: NSPoint? = nil,
|
||||
confirmUndo: Bool = true,
|
||||
) -> TerminalController {
|
||||
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
|
||||
|
||||
// Calculate the target frame based on the tree's view bounds
|
||||
let treeSize: CGSize? = tree.root?.viewBounds()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let window = c.window {
|
||||
// If we have a tree size, resize the window's content to match
|
||||
if let treeSize, treeSize.width > 0, treeSize.height > 0 {
|
||||
window.setContentSize(treeSize)
|
||||
window.constrainToScreen()
|
||||
}
|
||||
|
||||
if !window.styleMask.contains(.fullScreen) {
|
||||
if let position {
|
||||
window.setFrameTopLeftPoint(position)
|
||||
window.constrainToScreen()
|
||||
} else {
|
||||
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.showWindow(self)
|
||||
}
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager = c.undoManager {
|
||||
undoManager.setActionName("New Window")
|
||||
undoManager.registerUndo(
|
||||
withTarget: c,
|
||||
expiresAfter: c.undoExpiration
|
||||
) { target in
|
||||
undoManager.disableUndoRegistration {
|
||||
if confirmUndo {
|
||||
target.closeWindow(nil)
|
||||
} else {
|
||||
target.closeWindowImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(
|
||||
withTarget: ghostty,
|
||||
expiresAfter: target.undoExpiration
|
||||
) { ghostty in
|
||||
_ = TerminalController.newWindow(ghostty, tree: tree)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
static func newTab(
|
||||
_ ghostty: Ghostty.App,
|
||||
from parent: NSWindow? = nil,
|
||||
@@ -397,7 +483,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
//MARK: - Methods
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
@@ -548,7 +634,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
closeWindow(nil)
|
||||
}
|
||||
|
||||
private func closeTabImmediately(registerRedo: Bool = true) {
|
||||
func closeTabImmediately(registerRedo: Bool = true) {
|
||||
guard let window = window else { return }
|
||||
guard let tabGroup = window.tabGroup,
|
||||
tabGroup.windows.count > 1 else {
|
||||
@@ -671,7 +757,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
/// Closes the current window (including any other tabs) immediately and without
|
||||
/// confirmation. This will setup proper undo state so the action can be undone.
|
||||
private func closeWindowImmediately() {
|
||||
func closeWindowImmediately() {
|
||||
guard let window = window else { return }
|
||||
|
||||
registerUndoForCloseWindow()
|
||||
@@ -879,13 +965,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// Make it the key window
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
|
||||
// Restore focus to the previously focused surface
|
||||
if let focusedUUID = undoState.focusedSurface,
|
||||
let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusTarget, from: nil)
|
||||
}
|
||||
} else if let focusedSurface = surfaceTree.first {
|
||||
// No prior focused surface or we can't find it, let's focus
|
||||
// the first.
|
||||
self.focusedSurface = focusedSurface
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: focusedSurface, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,11 +1029,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
}
|
||||
|
||||
// Initialize our content view to the SwiftUI root
|
||||
window.contentView = NSHostingView(rootView: TerminalView(
|
||||
window.contentView = TerminalViewContainer(
|
||||
ghostty: self.ghostty,
|
||||
viewModel: self,
|
||||
delegate: self,
|
||||
))
|
||||
)
|
||||
|
||||
// If we have a default size, we want to apply it.
|
||||
if let defaultSize {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import GhosttyKit
|
||||
import os
|
||||
|
||||
/// This delegate is notified of actions and property changes regarding the terminal view. This
|
||||
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
|
||||
@@ -16,9 +17,9 @@ protocol TerminalViewDelegate: AnyObject {
|
||||
|
||||
/// Perform an action. At the time of writing this is only triggered by the command palette.
|
||||
func performAction(_ action: String, on: Ghostty.SurfaceView)
|
||||
|
||||
/// A split is resizing to a given value.
|
||||
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
|
||||
|
||||
/// A split tree operation
|
||||
func performSplitAction(_ action: TerminalSplitOperation)
|
||||
}
|
||||
|
||||
/// The view model is a required implementation for TerminalView callers. This contains
|
||||
@@ -81,7 +82,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
|
||||
TerminalSplitTreeView(
|
||||
tree: viewModel.surfaceTree,
|
||||
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
|
||||
action: { delegate?.performSplitAction($0) })
|
||||
.environmentObject(ghostty)
|
||||
.focused($focused)
|
||||
.onAppear { self.focused = true }
|
||||
|
||||
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
160
macos/Sources/Features/Terminal/TerminalViewContainer.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Use this container to achieve a glass effect at the window level.
|
||||
/// Modifying `NSThemeFrame` can sometimes be unpredictable.
|
||||
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
|
||||
private let terminalView: NSView
|
||||
|
||||
/// Glass effect view for liquid glass background when transparency is enabled
|
||||
private var glassEffectView: NSView?
|
||||
private var glassTopConstraint: NSLayoutConstraint?
|
||||
private var derivedConfig: DerivedConfig
|
||||
|
||||
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
|
||||
self.derivedConfig = DerivedConfig(config: ghostty.config)
|
||||
self.terminalView = NSHostingView(rootView: TerminalView(
|
||||
ghostty: ghostty,
|
||||
viewModel: viewModel,
|
||||
delegate: delegate
|
||||
))
|
||||
super.init(frame: .zero)
|
||||
setup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// To make ``TerminalController/DefaultSize/contentIntrinsicSize``
|
||||
/// work in ``TerminalController/windowDidLoad()``,
|
||||
/// we override this to provide the correct size.
|
||||
override var intrinsicContentSize: NSSize {
|
||||
terminalView.intrinsicContentSize
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
addSubview(terminalView)
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.topAnchor.constraint(equalTo: topAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
terminalView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||
name: .ghosttyConfigDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
updateGlassEffectIfNeeded()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
||||
guard let config = notification.userInfo?[
|
||||
Notification.Name.GhosttyConfigChangeKey
|
||||
] as? Ghostty.Config else { return }
|
||||
let newValue = DerivedConfig(config: config)
|
||||
guard newValue != derivedConfig else { return }
|
||||
derivedConfig = newValue
|
||||
DispatchQueue.main.async(execute: updateGlassEffectIfNeeded)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Glass
|
||||
|
||||
private extension TerminalViewContainer {
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
|
||||
if let existed = glassEffectView as? NSGlassEffectView {
|
||||
updateGlassEffectTopInsetIfNeeded()
|
||||
return existed
|
||||
}
|
||||
guard let themeFrameView = window?.contentView?.superview else {
|
||||
return nil
|
||||
}
|
||||
let effectView = NSGlassEffectView()
|
||||
addSubview(effectView, positioned: .below, relativeTo: terminalView)
|
||||
effectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
glassTopConstraint = effectView.topAnchor.constraint(
|
||||
equalTo: topAnchor,
|
||||
constant: -themeFrameView.safeAreaInsets.top
|
||||
)
|
||||
if let glassTopConstraint {
|
||||
NSLayoutConstraint.activate([
|
||||
glassTopConstraint,
|
||||
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
glassEffectView = effectView
|
||||
return effectView
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
|
||||
func updateGlassEffectIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
glassEffectView?.removeFromSuperview()
|
||||
glassEffectView = nil
|
||||
glassTopConstraint = nil
|
||||
return
|
||||
}
|
||||
guard let effectView = addGlassEffectViewIfNeeded() else {
|
||||
return
|
||||
}
|
||||
switch derivedConfig.backgroundBlur {
|
||||
case .macosGlassRegular:
|
||||
effectView.style = NSGlassEffectView.Style.regular
|
||||
case .macosGlassClear:
|
||||
effectView.style = NSGlassEffectView.Style.clear
|
||||
default:
|
||||
break
|
||||
}
|
||||
let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor)
|
||||
effectView.tintColor = backgroundColor
|
||||
.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||
if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat {
|
||||
effectView.cornerRadius = cornerRadius
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
func updateGlassEffectTopInsetIfNeeded() {
|
||||
#if compiler(>=6.2)
|
||||
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
|
||||
return
|
||||
}
|
||||
guard glassEffectView != nil else { return }
|
||||
guard let themeFrameView = window?.contentView?.superview else { return }
|
||||
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
|
||||
#endif // compiler(>=6.2)
|
||||
}
|
||||
|
||||
struct DerivedConfig: Equatable {
|
||||
var backgroundOpacity: Double = 0
|
||||
var backgroundBlur: Ghostty.Config.BackgroundBlur
|
||||
var backgroundColor: Color = .clear
|
||||
|
||||
init(config: Ghostty.Config) {
|
||||
self.backgroundBlur = config.backgroundBlur
|
||||
self.backgroundOpacity = config.backgroundOpacity
|
||||
self.backgroundColor = config.backgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
// Its possible we miss the accessory titlebar call so we check again
|
||||
// whenever the window becomes main. Both of these are idempotent.
|
||||
if hasTabBar {
|
||||
if tabBarView != nil {
|
||||
tabBarDidAppear()
|
||||
} else {
|
||||
tabBarDidDisappear()
|
||||
@@ -243,31 +243,6 @@ class TerminalWindow: NSWindow {
|
||||
/// added.
|
||||
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||
|
||||
func findTitlebarView() -> NSView? {
|
||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||
//
|
||||
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||
guard let themeFrameView = contentView?.rootView else { return nil }
|
||||
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
|
||||
themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||
} else {
|
||||
NSView?.none
|
||||
}
|
||||
return titlebarView
|
||||
}
|
||||
|
||||
func findTabBar() -> NSView? {
|
||||
findTitlebarView()?.firstDescendant(withClassName: "NSTabBar")
|
||||
}
|
||||
|
||||
/// Returns true if there is a tab bar visible on this window.
|
||||
var hasTabBar: Bool {
|
||||
findTabBar() != nil
|
||||
}
|
||||
|
||||
var hasMoreThanOneTabs: Bool {
|
||||
/// accessing ``tabGroup?.windows`` here
|
||||
/// will cause other edge cases, be careful
|
||||
@@ -474,7 +449,7 @@ class TerminalWindow: NSWindow {
|
||||
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
|
||||
if !styleMask.contains(.fullScreen) &&
|
||||
!forceOpaque &&
|
||||
surfaceConfig.backgroundOpacity < 1
|
||||
(surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle)
|
||||
{
|
||||
isOpaque = false
|
||||
|
||||
@@ -483,15 +458,8 @@ class TerminalWindow: NSWindow {
|
||||
// Terminal.app more easily.
|
||||
backgroundColor = .white.withAlphaComponent(0.001)
|
||||
|
||||
// Add liquid glass behind terminal content
|
||||
if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle {
|
||||
setupGlassLayer()
|
||||
} else if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
// If we had a prior glass layer we should remove it
|
||||
if #available(macOS 26.0, *) {
|
||||
removeGlassLayer()
|
||||
}
|
||||
|
||||
// We don't need to set blur when using glass
|
||||
if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
ghostty_set_window_background_blur(
|
||||
appDelegate.ghostty.app,
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
@@ -499,11 +467,6 @@ class TerminalWindow: NSWindow {
|
||||
} else {
|
||||
isOpaque = true
|
||||
|
||||
// Remove liquid glass when not transparent
|
||||
if #available(macOS 26.0, *) {
|
||||
removeGlassLayer()
|
||||
}
|
||||
|
||||
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
|
||||
self.backgroundColor = backgroundColor.withAlphaComponent(1)
|
||||
}
|
||||
@@ -581,50 +544,6 @@ class TerminalWindow: NSWindow {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
// MARK: Glass
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func setupGlassLayer() {
|
||||
// Remove existing glass effect view
|
||||
removeGlassLayer()
|
||||
|
||||
// Get the window content view (parent of the NSHostingView)
|
||||
guard let contentView else { return }
|
||||
guard let windowContentView = contentView.superview else { return }
|
||||
|
||||
// Create NSGlassEffectView for native glass effect
|
||||
let effectView = NSGlassEffectView()
|
||||
|
||||
// Map Ghostty config to NSGlassEffectView style
|
||||
switch derivedConfig.backgroundBlur {
|
||||
case .macosGlassRegular:
|
||||
effectView.style = NSGlassEffectView.Style.regular
|
||||
case .macosGlassClear:
|
||||
effectView.style = NSGlassEffectView.Style.clear
|
||||
default:
|
||||
// Should not reach here since we check for glass style before calling
|
||||
// setupGlassLayer()
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
effectView.cornerRadius = derivedConfig.windowCornerRadius
|
||||
effectView.tintColor = preferredBackgroundColor
|
||||
effectView.frame = windowContentView.bounds
|
||||
effectView.autoresizingMask = [.width, .height]
|
||||
|
||||
// Position BELOW the terminal content to act as background
|
||||
windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView)
|
||||
glassEffectView = effectView
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func removeGlassLayer() {
|
||||
glassEffectView?.removeFromSuperview()
|
||||
glassEffectView = nil
|
||||
}
|
||||
#endif // compiler(>=6.2)
|
||||
|
||||
// MARK: Config
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
return
|
||||
}
|
||||
|
||||
guard let tabBarView = findTabBar() else {
|
||||
guard let tabBarView else {
|
||||
super.sendEvent(event)
|
||||
return
|
||||
}
|
||||
@@ -176,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
guard tabBarObserver == nil else { return }
|
||||
|
||||
guard
|
||||
let titlebarView = findTitlebarView(),
|
||||
let tabBar = findTabBar()
|
||||
let titlebarView,
|
||||
let tabBarView = self.tabBarView
|
||||
else { return }
|
||||
|
||||
// View model updates must happen on their own ticks.
|
||||
@@ -186,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
}
|
||||
|
||||
// Find our clip view
|
||||
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
||||
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||
|
||||
// Make sure tabBar's height won't be stretched
|
||||
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
|
||||
tabBar.frame.size.height = newTabButton.frame.width
|
||||
tabBarView.frame.size.height = newTabButton.frame.width
|
||||
|
||||
// The container is the view that we'll constrain our tab bar within.
|
||||
let container = toolbarView
|
||||
@@ -228,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
// other events occur, the tab bar can resize and clear our constraints. When this
|
||||
// happens, we need to remove our custom constraints and re-apply them once the
|
||||
// tab bar has proper dimensions again to avoid constraint conflicts.
|
||||
tabBar.postsFrameChangedNotifications = true
|
||||
tabBarView.postsFrameChangedNotifications = true
|
||||
tabBarObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: tabBar,
|
||||
object: tabBarView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -322,7 +322,8 @@ extension TitlebarTabsTahoeTerminalWindow {
|
||||
} else {
|
||||
// 1x1.gif strikes again! For real: if we render a zero-sized
|
||||
// view here then the toolbar just disappears our view. I don't
|
||||
// know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
|
||||
// know. On macOS 26.1+ the view no longer disappears, but the
|
||||
// toolbar still logs an ambiguous content size warning.
|
||||
Color.clear.frame(width: 1, height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
10
macos/Sources/Ghostty/GhosttyDelegate.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension Ghostty {
|
||||
/// This is a delegate that should be applied to your global app delegate for GhosttyKit
|
||||
/// to perform app-global operations.
|
||||
protocol Delegate {
|
||||
/// Look up a surface within the application by ID.
|
||||
func ghosttySurface(id: UUID) -> SurfaceView?
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,22 @@ extension Ghostty {
|
||||
case xray
|
||||
case custom
|
||||
case customStyle = "custom-style"
|
||||
|
||||
/// Bundled asset name for built-in icons
|
||||
var assetName: String? {
|
||||
switch self {
|
||||
case .official: return nil
|
||||
case .blueprint: return "BlueprintImage"
|
||||
case .chalkboard: return "ChalkboardImage"
|
||||
case .microchip: return "MicrochipImage"
|
||||
case .glass: return "GlassImage"
|
||||
case .holographic: return "HolographicImage"
|
||||
case .paper: return "PaperImage"
|
||||
case .retro: return "RetroImage"
|
||||
case .xray: return "XrayImage"
|
||||
case .custom, .customStyle: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macos-icon-frame
|
||||
|
||||
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
268
macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A preference key that propagates the ID of the SurfaceView currently being dragged,
|
||||
/// or nil if no surface is being dragged.
|
||||
struct DraggingSurfaceKey: PreferenceKey {
|
||||
static var defaultValue: SurfaceView.ID? = nil
|
||||
|
||||
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
|
||||
///
|
||||
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
|
||||
/// of terminal surfaces within split views. When the user drags this view, it initiates
|
||||
/// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing
|
||||
/// drop targets to identify which surface is being moved.
|
||||
///
|
||||
/// The view also publishes the dragging state via `DraggingSurfaceKey` preference,
|
||||
/// enabling parent views to react to ongoing drag operations.
|
||||
struct SurfaceDragSource: View {
|
||||
/// The surface view that will be dragged.
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
/// Binding that reflects whether a drag session is currently active.
|
||||
@Binding var isDragging: Bool
|
||||
|
||||
/// Binding that reflects whether the mouse is hovering over this view.
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
var body: some View {
|
||||
SurfaceDragSourceViewRepresentable(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering)
|
||||
.preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// An NSViewRepresentable that provides AppKit-based drag source functionality.
|
||||
/// This gives us control over the drag lifecycle, particularly detecting drag start.
|
||||
fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable {
|
||||
let surfaceView: SurfaceView
|
||||
@Binding var isDragging: Bool
|
||||
@Binding var isHovering: Bool
|
||||
|
||||
func makeNSView(context: Context) -> SurfaceDragSourceView {
|
||||
let view = SurfaceDragSourceView()
|
||||
view.surfaceView = surfaceView
|
||||
view.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
view.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
|
||||
nsView.surfaceView = surfaceView
|
||||
nsView.onDragStateChanged = { dragging in
|
||||
isDragging = dragging
|
||||
}
|
||||
nsView.onHoverChanged = { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying NSView that handles drag operations.
|
||||
///
|
||||
/// This view manages mouse tracking and drag initiation for surface reordering.
|
||||
/// It uses a local event loop to detect drag gestures and initiates an
|
||||
/// `NSDraggingSession` when the user drags beyond the threshold distance.
|
||||
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
|
||||
/// Scale factor applied to the surface snapshot for the drag preview image.
|
||||
private static let previewScale: CGFloat = 0.2
|
||||
|
||||
/// The surface view that will be dragged. Its UUID is encoded into the
|
||||
/// pasteboard for drop targets to identify which surface is being moved.
|
||||
var surfaceView: SurfaceView?
|
||||
|
||||
/// Callback invoked when the drag state changes. Called with `true` when
|
||||
/// a drag session begins, and `false` when it ends (completed or cancelled).
|
||||
var onDragStateChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Callback invoked when the mouse enters or exits this view's bounds.
|
||||
/// Used to update the hover state for visual feedback in the parent view.
|
||||
var onHoverChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Whether we are currently in a mouse tracking loop (between mouseDown
|
||||
/// and either mouseUp or drag initiation). Used to determine cursor state.
|
||||
private var isTracking: Bool = false
|
||||
|
||||
/// Local event monitor to detect escape key presses during drag.
|
||||
private var escapeMonitor: Any?
|
||||
|
||||
/// Whether the current drag was cancelled by pressing escape.
|
||||
private var dragCancelledByEscape: Bool = false
|
||||
|
||||
deinit {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||
// Ensure this view gets the mouse event before window dragging handlers
|
||||
return true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
// Consume the mouseDown event to prevent it from propagating to the
|
||||
// window's drag handler. This fixes issue #10110 where grab handles
|
||||
// would drag the window instead of initiating pane drags.
|
||||
// Don't call super - the drag will be initiated in mouseDragged.
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
|
||||
// To update our tracking area we just recreate it all.
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
|
||||
// Add our tracking area for mouse events
|
||||
addTrackingArea(NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: [.mouseEnteredAndExited, .activeInActiveApp],
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
))
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
onHoverChanged?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
onHoverChanged?(false)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard !isTracking, let surfaceView = surfaceView else { return }
|
||||
|
||||
// Create our dragging item from our transferable
|
||||
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
|
||||
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
|
||||
// Create a scaled preview image from the surface snapshot
|
||||
if let snapshot = surfaceView.asImage {
|
||||
let imageSize = NSSize(
|
||||
width: snapshot.size.width * Self.previewScale,
|
||||
height: snapshot.size.height * Self.previewScale
|
||||
)
|
||||
let scaledImage = NSImage(size: imageSize)
|
||||
scaledImage.lockFocus()
|
||||
snapshot.draw(
|
||||
in: NSRect(origin: .zero, size: imageSize),
|
||||
from: NSRect(origin: .zero, size: snapshot.size),
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
)
|
||||
scaledImage.unlockFocus()
|
||||
|
||||
// Position the drag image so the mouse is at the center of the image.
|
||||
// I personally like the top middle or top left corner best but
|
||||
// this matches macOS native tab dragging behavior (at least, as of
|
||||
// macOS 26.2 on Dec 29, 2025).
|
||||
let mouseLocation = convert(event.locationInWindow, from: nil)
|
||||
let origin = NSPoint(
|
||||
x: mouseLocation.x - imageSize.width / 2,
|
||||
y: mouseLocation.y - imageSize.height / 2
|
||||
)
|
||||
item.setDraggingFrame(
|
||||
NSRect(origin: origin, size: imageSize),
|
||||
contents: scaledImage
|
||||
)
|
||||
}
|
||||
|
||||
onDragStateChanged?(true)
|
||||
let session = beginDraggingSession(with: [item], event: event, source: self)
|
||||
|
||||
// We need to disable this so that endedAt happens immediately for our
|
||||
// drags outside of any targets.
|
||||
session.animatesToStartingPositionsOnCancelOrFail = false
|
||||
}
|
||||
|
||||
// MARK: NSDraggingSource
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
sourceOperationMaskFor context: NSDraggingContext
|
||||
) -> NSDragOperation {
|
||||
return context == .withinApplication ? .move : []
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
willBeginAt screenPoint: NSPoint
|
||||
) {
|
||||
isTracking = true
|
||||
|
||||
// Reset our escape tracking
|
||||
dragCancelledByEscape = false
|
||||
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
if event.keyCode == 53 { // Escape key
|
||||
self?.dragCancelledByEscape = true
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
movedTo screenPoint: NSPoint
|
||||
) {
|
||||
NSCursor.closedHand.set()
|
||||
}
|
||||
|
||||
func draggingSession(
|
||||
_ session: NSDraggingSession,
|
||||
endedAt screenPoint: NSPoint,
|
||||
operation: NSDragOperation
|
||||
) {
|
||||
if let escapeMonitor {
|
||||
NSEvent.removeMonitor(escapeMonitor)
|
||||
self.escapeMonitor = nil
|
||||
}
|
||||
|
||||
if operation == [] && !dragCancelledByEscape {
|
||||
let endsInWindow = NSApplication.shared.windows.contains { window in
|
||||
window.isVisible && window.frame.contains(screenPoint)
|
||||
}
|
||||
if !endsInWindow {
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttySurfaceDragEndedNoTarget,
|
||||
object: surfaceView,
|
||||
userInfo: [Foundation.Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey: screenPoint]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isTracking = false
|
||||
onDragStateChanged?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when a surface drag session ends with no operation (the drag was
|
||||
/// released outside a valid drop target) and was not cancelled by the user
|
||||
/// pressing escape. The notification's object is the SurfaceView that was dragged.
|
||||
static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget")
|
||||
|
||||
/// Key for the screen point where the drag ended in the userInfo dictionary.
|
||||
static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint"
|
||||
}
|
||||
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
41
macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension Ghostty {
|
||||
/// A grab handle overlay at the top of the surface for dragging the window.
|
||||
/// Only appears when hovering in the top region of the surface.
|
||||
struct SurfaceGrabHandle: View {
|
||||
private let handleHeight: CGFloat = 10
|
||||
|
||||
let surfaceView: SurfaceView
|
||||
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var isDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0))
|
||||
.frame(height: handleHeight)
|
||||
.overlay(alignment: .center) {
|
||||
if isHovering || isDragging {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
SurfaceDragSource(
|
||||
surfaceView: surfaceView,
|
||||
isDragging: $isDragging,
|
||||
isHovering: $isHovering
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,18 +120,20 @@ class SurfaceScrollView: NSView {
|
||||
self?.handleScrollerStyleChange()
|
||||
})
|
||||
|
||||
// Listen for frame change events. See the docstring for
|
||||
// handleFrameChange for why this is necessary.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChange(notification)
|
||||
})
|
||||
// Listen for frame change events on macOS 26.0. See the docstring for
|
||||
// handleFrameChangeForNSScrollPocket for why this is necessary.
|
||||
if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) {
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: nil,
|
||||
// Since this observer is used to immediately override the event
|
||||
// that produced the notification, we let it run synchronously on
|
||||
// the posting thread.
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
self?.handleFrameChangeForNSScrollPocket(notification)
|
||||
})
|
||||
}}
|
||||
|
||||
// Listen for derived config changes to update scrollbar settings live
|
||||
surfaceView.$derivedConfig
|
||||
@@ -328,7 +330,10 @@ class SurfaceScrollView: NSView {
|
||||
/// and reset their frame to zero.
|
||||
///
|
||||
/// See also https://developer.apple.com/forums/thread/798392.
|
||||
private func handleFrameChange(_ notification: Notification) {
|
||||
///
|
||||
/// This bug is only present in macOS 26.0.
|
||||
@available(macOS, introduced: 26.0, obsoleted: 26.1)
|
||||
private func handleFrameChangeForNSScrollPocket(_ notification: Notification) {
|
||||
guard let window = window as? HiddenTitlebarTerminalWindow else { return }
|
||||
guard !window.styleMask.contains(.fullScreen) else { return }
|
||||
guard let view = notification.object as? NSView else { return }
|
||||
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
28
macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Ghostty.SurfaceView {
|
||||
#if canImport(AppKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: NSImage? {
|
||||
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
|
||||
return nil
|
||||
}
|
||||
cacheDisplay(in: bounds, to: bitmapRep)
|
||||
let image = NSImage(size: bounds.size)
|
||||
image.addRepresentation(bitmapRep)
|
||||
return image
|
||||
}
|
||||
#elseif canImport(UIKit)
|
||||
/// A snapshot image of the current surface view.
|
||||
var asImage: UIImage? {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
return renderer.image { _ in
|
||||
drawHierarchy(in: bounds, afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Conformance to `Transferable` enables drag-and-drop.
|
||||
extension Ghostty.SurfaceView: Transferable {
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .ghosttySurfaceId) { surface in
|
||||
withUnsafeBytes(of: surface.id.uuid) { Data($0) }
|
||||
} importing: { data in
|
||||
guard data.count == 16 else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
let uuid = data.withUnsafeBytes {
|
||||
$0.load(as: UUID.self)
|
||||
}
|
||||
|
||||
guard let imported = await Self.find(uuid: uuid) else {
|
||||
throw TransferError.invalidData
|
||||
}
|
||||
|
||||
return imported
|
||||
}
|
||||
}
|
||||
|
||||
enum TransferError: Error {
|
||||
case invalidData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func find(uuid: UUID) -> Self? {
|
||||
#if canImport(AppKit)
|
||||
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
|
||||
return del.ghosttySurface(id: uuid) as? Self
|
||||
#elseif canImport(UIKit)
|
||||
// We should be able to use UIApplication here.
|
||||
return nil
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension UTType {
|
||||
/// A format that encodes the bare UUID only for the surface. This can be used if you have
|
||||
/// a way to look up a surface by ID.
|
||||
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
extension NSPasteboard.PasteboardType {
|
||||
/// Pasteboard type for dragging surface IDs.
|
||||
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
|
||||
}
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,19 +0,0 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
struct DraggableWindowView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> DraggableWindowNSView {
|
||||
return DraggableWindowNSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DraggableWindowNSView, context: Context) {
|
||||
// No need to update anything here
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableWindowNSView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window = self.window else { return }
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
}
|
||||
@@ -10,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
124
macos/Tests/Helpers/TransferablePasteboardTests.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Testing
|
||||
import AppKit
|
||||
import CoreTransferable
|
||||
import UniformTypeIdentifiers
|
||||
@testable import Ghostty
|
||||
|
||||
struct TransferablePasteboardTests {
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// A simple Transferable type for testing pasteboard conversion.
|
||||
private struct DummyTransferable: Transferable, Equatable {
|
||||
let payload: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.payload.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
let string = String(data: data, encoding: .utf8)!
|
||||
return DummyTransferable(payload: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transferable type that registers multiple content types.
|
||||
private struct MultiTypeTransferable: Transferable {
|
||||
let text: String
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
DataRepresentation(contentType: .utf8PlainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
DataRepresentation(contentType: .plainText) { value in
|
||||
value.text.data(using: .utf8)!
|
||||
} importing: { data in
|
||||
MultiTypeTransferable(text: String(data: data, encoding: .utf8)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Basic Functionality
|
||||
|
||||
@Test func pasteboardItemIsCreated() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
let item = transferable.pasteboardItem()
|
||||
#expect(item != nil)
|
||||
}
|
||||
|
||||
@Test func pasteboardItemContainsExpectedType() {
|
||||
let transferable = DummyTransferable(payload: "hello")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
#expect(item.types.contains(expectedType))
|
||||
}
|
||||
|
||||
@Test func pasteboardItemProvidesCorrectData() {
|
||||
let transferable = DummyTransferable(payload: "test data")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
|
||||
// Write to a pasteboard to trigger data provider
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Read back the data
|
||||
guard let data = pasteboard.data(forType: pasteboardType) else {
|
||||
Issue.record("Expected data to be available on pasteboard")
|
||||
return
|
||||
}
|
||||
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
#expect(string == "test data")
|
||||
}
|
||||
|
||||
// MARK: - Multiple Content Types
|
||||
|
||||
@Test func multipleTypesAreRegistered() {
|
||||
let transferable = MultiTypeTransferable(text: "multi")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
#expect(item.types.contains(utf8Type))
|
||||
#expect(item.types.contains(plainType))
|
||||
}
|
||||
|
||||
@Test func multipleTypesProvideCorrectData() {
|
||||
let transferable = MultiTypeTransferable(text: "shared content")
|
||||
guard let item = transferable.pasteboardItem() else {
|
||||
Issue.record("Expected pasteboard item to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)"))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([item])
|
||||
|
||||
// Both types should provide the same content
|
||||
let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)
|
||||
let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier)
|
||||
|
||||
if let utf8Data = pasteboard.data(forType: utf8Type) {
|
||||
#expect(String(data: utf8Data, encoding: .utf8) == "shared content")
|
||||
}
|
||||
|
||||
if let plainData = pasteboard.data(forType: plainType) {
|
||||
#expect(String(data: plainData, encoding: .utf8) == "shared content")
|
||||
}
|
||||
}
|
||||
}
|
||||
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
128
macos/Tests/Splits/TerminalSplitDropZoneTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Ghostty
|
||||
|
||||
struct TerminalSplitDropZoneTests {
|
||||
private let standardSize = CGSize(width: 100, height: 100)
|
||||
|
||||
// MARK: - Basic Edge Detection
|
||||
|
||||
@Test func topEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func bottomEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
@Test func leftEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func rightEdge() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Corner Tie-Breaking
|
||||
// When distances are equal, the check order determines the result:
|
||||
// left -> right -> top -> bottom
|
||||
|
||||
@Test func topLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func topRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func bottomLeftCornerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func bottomRightCornerSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
// MARK: - Center Point (All Distances Equal)
|
||||
|
||||
@Test func centerSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
// MARK: - Non-Square Aspect Ratio
|
||||
|
||||
@Test func rectangularViewTopEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func rectangularViewLeftEdge() {
|
||||
let size = CGSize(width: 200, height: 100)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func tallRectangleTopEdge() {
|
||||
let size = CGSize(width: 100, height: 200)
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
// MARK: - Out-of-Bounds Points
|
||||
|
||||
@Test func pointLeftOfViewSelectsLeft() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func pointAboveViewSelectsTop() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize)
|
||||
#expect(zone == .top)
|
||||
}
|
||||
|
||||
@Test func pointRightOfViewSelectsRight() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func pointBelowViewSelectsBottom() {
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize)
|
||||
#expect(zone == .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Diagonal Regions (Triangular Zones)
|
||||
|
||||
@Test func upperLeftTriangleSelectsLeft() {
|
||||
// Point in the upper-left triangle, closer to left than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func upperRightTriangleSelectsRight() {
|
||||
// Point in the upper-right triangle, closer to right than top
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
|
||||
@Test func lowerLeftTriangleSelectsLeft() {
|
||||
// Point in the lower-left triangle, closer to left than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize)
|
||||
#expect(zone == .left)
|
||||
}
|
||||
|
||||
@Test func lowerRightTriangleSelectsRight() {
|
||||
// Point in the lower-right triangle, closer to right than bottom
|
||||
let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize)
|
||||
#expect(zone == .right)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
wasmtime,
|
||||
wraptest,
|
||||
zig,
|
||||
zig_0_15,
|
||||
zip,
|
||||
llvmPackages_latest,
|
||||
bzip2,
|
||||
|
||||
@@ -20,16 +20,6 @@
|
||||
wayland-scanner,
|
||||
pkgs,
|
||||
}: let
|
||||
# The Zig hook has no way to select the release type without actual
|
||||
# overriding of the default flags.
|
||||
#
|
||||
# TODO: Once
|
||||
# https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is
|
||||
# ultimately acted on and has made its way to a nixpkgs implementation, this
|
||||
# can probably be removed in favor of that.
|
||||
zig_hook = zig_0_15.hook.overrideAttrs {
|
||||
zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off";
|
||||
};
|
||||
gi_typelib_path = import ./build-support/gi-typelib-path.nix {
|
||||
inherit pkgs lib stdenv;
|
||||
};
|
||||
@@ -73,7 +63,7 @@ in
|
||||
ncurses
|
||||
pandoc
|
||||
pkg-config
|
||||
zig_hook
|
||||
zig_0_15
|
||||
gobject-introspection
|
||||
wrapGAppsHook4
|
||||
blueprint-compiler
|
||||
@@ -92,12 +82,16 @@ in
|
||||
|
||||
GI_TYPELIB_PATH = gi_typelib_path;
|
||||
|
||||
dontSetZigDefaultFlags = true;
|
||||
|
||||
zigBuildFlags = [
|
||||
"--system"
|
||||
"${finalAttrs.deps}"
|
||||
"-Dversion-string=${finalAttrs.version}-${revision}-nix"
|
||||
"-Dgtk-x11=${lib.boolToString enableX11}"
|
||||
"-Dgtk-wayland=${lib.boolToString enableWayland}"
|
||||
"-Dcpu=baseline"
|
||||
"-Doptimize=${optimize}"
|
||||
"-Dstrip=${lib.boolToString strip}"
|
||||
];
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ in {
|
||||
client.succeed("${su "${ghostty} +new-window"}")
|
||||
client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
|
||||
|
||||
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.")
|
||||
with subtest("SSH from client to server and verify that the Ghostty terminfo is copied."):
|
||||
client.sleep(2)
|
||||
client.send_chars("ssh ghostty@server\n")
|
||||
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
./common.nix
|
||||
];
|
||||
|
||||
services.xserver = {
|
||||
services = {
|
||||
displayManager = {
|
||||
gdm = {
|
||||
enable = true;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{...}: {
|
||||
imports = [
|
||||
./common-gnome.nix
|
||||
];
|
||||
|
||||
services.displayManager = {
|
||||
defaultSession = "gnome-xorg";
|
||||
};
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.{
|
||||
.name = .cimgui,
|
||||
.version = "1.90.6", // -docking branch
|
||||
.fingerprint = 0x49726f5f8acbc90d,
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// This should be kept in sync with the submodule in the cimgui source
|
||||
// code in ./vendor/ to be safe that they're compatible.
|
||||
.imgui = .{
|
||||
// ocornut/imgui
|
||||
.url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||
.hash = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
.freetype = .{ .path = "../freetype" },
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub const c = @cImport({
|
||||
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1");
|
||||
@cInclude("cimgui.h");
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
pub const c = @import("c.zig").c;
|
||||
|
||||
// OpenGL
|
||||
pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.c) void;
|
||||
|
||||
// Metal
|
||||
pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.c) void;
|
||||
|
||||
// OSX
|
||||
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
|
||||
|
||||
test {}
|
||||
5943
pkg/cimgui/vendor/cimgui.cpp
vendored
5943
pkg/cimgui/vendor/cimgui.cpp
vendored
File diff suppressed because it is too large
Load Diff
6554
pkg/cimgui/vendor/cimgui.h
vendored
6554
pkg/cimgui/vendor/cimgui.h
vendored
File diff suppressed because it is too large
Load Diff
199
pkg/dcimgui/build.zig
Normal file
199
pkg/dcimgui/build.zig
Normal file
@@ -0,0 +1,199 @@
|
||||
const std = @import("std");
|
||||
const NativeTargetInfo = std.zig.system.NativeTargetInfo;
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
const freetype = b.option(bool, "freetype", "Use Freetype") orelse false;
|
||||
const backend_opengl3 = b.option(bool, "backend-opengl3", "OpenGL3 backend") orelse false;
|
||||
const backend_metal = b.option(bool, "backend-metal", "Metal backend") orelse false;
|
||||
const backend_osx = b.option(bool, "backend-osx", "OSX backend") orelse false;
|
||||
|
||||
// Build options
|
||||
const options = b.addOptions();
|
||||
options.addOption(bool, "freetype", freetype);
|
||||
options.addOption(bool, "backend_opengl3", backend_opengl3);
|
||||
options.addOption(bool, "backend_metal", backend_metal);
|
||||
options.addOption(bool, "backend_osx", backend_osx);
|
||||
|
||||
// Main static lib
|
||||
const lib = b.addLibrary(.{
|
||||
.name = "dcimgui",
|
||||
.root_module = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
.linkage = .static,
|
||||
});
|
||||
lib.linkLibC();
|
||||
lib.linkLibCpp();
|
||||
b.installArtifact(lib);
|
||||
|
||||
// Zig module
|
||||
const mod = b.addModule("dcimgui", .{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
mod.addOptions("build_options", options);
|
||||
mod.linkLibrary(lib);
|
||||
|
||||
// We need to add proper Apple SDKs to find stdlib headers
|
||||
if (target.result.os.tag.isDarwin()) {
|
||||
if (!target.query.isNative()) {
|
||||
try @import("apple_sdk").addPaths(b, lib);
|
||||
}
|
||||
}
|
||||
|
||||
// Flags for C compilation, common to all.
|
||||
var flags: std.ArrayList([]const u8) = .empty;
|
||||
defer flags.deinit(b.allocator);
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_USE_WCHAR32=1",
|
||||
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
|
||||
});
|
||||
if (freetype) try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_ENABLE_FREETYPE=1",
|
||||
});
|
||||
if (target.result.os.tag == .windows) {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)",
|
||||
});
|
||||
} else {
|
||||
try flags.appendSlice(b.allocator, &.{
|
||||
"-DIMGUI_IMPL_API=extern\t\"C\"",
|
||||
});
|
||||
}
|
||||
if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
|
||||
try flags.append(b.allocator, "-fPIC");
|
||||
}
|
||||
|
||||
// Add the core Dear Imgui source files
|
||||
if (b.lazyDependency("imgui", .{})) |upstream| {
|
||||
lib.addIncludePath(upstream.path(""));
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path(""),
|
||||
.files = &.{
|
||||
"imgui_demo.cpp",
|
||||
"imgui_draw.cpp",
|
||||
"imgui_tables.cpp",
|
||||
"imgui_widgets.cpp",
|
||||
"imgui.cpp",
|
||||
},
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path(""),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
|
||||
if (freetype) {
|
||||
lib.addCSourceFile(.{
|
||||
.file = upstream.path("misc/freetype/imgui_freetype.cpp"),
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
if (b.systemIntegrationOption("freetype", .{})) {
|
||||
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
|
||||
} else {
|
||||
const freetype_dep = b.dependency("freetype", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.@"enable-libpng" = true,
|
||||
});
|
||||
lib.linkLibrary(freetype_dep.artifact("freetype"));
|
||||
if (freetype_dep.builder.lazyDependency(
|
||||
"freetype",
|
||||
.{},
|
||||
)) |freetype_upstream| {
|
||||
mod.addIncludePath(freetype_upstream.path("include"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (backend_metal) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_metal.mm"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_metal.h"} },
|
||||
);
|
||||
}
|
||||
if (backend_osx) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_osx.mm"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_osx.h"} },
|
||||
);
|
||||
}
|
||||
if (backend_opengl3) {
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path("backends"),
|
||||
.files = &.{"imgui_impl_opengl3.cpp"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path("backends"),
|
||||
"",
|
||||
.{ .include_extensions = &.{"imgui_impl_opengl3.h"} },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the C bindings
|
||||
if (b.lazyDependency("bindings", .{})) |upstream| {
|
||||
lib.addIncludePath(upstream.path(""));
|
||||
lib.addCSourceFiles(.{
|
||||
.root = upstream.path(""),
|
||||
.files = &.{
|
||||
"dcimgui.cpp",
|
||||
"dcimgui_internal.cpp",
|
||||
},
|
||||
.flags = flags.items,
|
||||
});
|
||||
lib.addCSourceFiles(.{
|
||||
.root = b.path(""),
|
||||
.files = &.{"ext.cpp"},
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
lib.installHeadersDirectory(
|
||||
upstream.path(""),
|
||||
"",
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
}
|
||||
|
||||
const test_exe = b.addTest(.{
|
||||
.name = "test",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
test_exe.root_module.addOptions("build_options", options);
|
||||
test_exe.linkLibrary(lib);
|
||||
const tests_run = b.addRunArtifact(test_exe);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&tests_run.step);
|
||||
}
|
||||
|
||||
// For dynamic linking, we prefer dynamic linking and to search by
|
||||
// mode first. Mode first will search all paths for a dynamic library
|
||||
// before falling back to static.
|
||||
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
|
||||
.preferred_link_mode = .dynamic,
|
||||
.search_strategy = .mode_first,
|
||||
};
|
||||
26
pkg/dcimgui/build.zig.zon
Normal file
26
pkg/dcimgui/build.zig.zon
Normal file
@@ -0,0 +1,26 @@
|
||||
.{
|
||||
.name = .dcimgui,
|
||||
.version = "1.92.5", // -docking branch
|
||||
.fingerprint = 0x1a25797442c6324f,
|
||||
.paths = .{""},
|
||||
.dependencies = .{
|
||||
// The bindings and imgui versions below must match exactly.
|
||||
|
||||
.bindings = .{
|
||||
// https://github.com/dearimgui/dear_bindings
|
||||
.url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz",
|
||||
.hash = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.imgui = .{
|
||||
// https://github.com/ocornut/imgui
|
||||
.url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
|
||||
.hash = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI",
|
||||
.lazy = true,
|
||||
},
|
||||
|
||||
.apple_sdk = .{ .path = "../apple-sdk" },
|
||||
.freetype = .{ .path = "../freetype" },
|
||||
},
|
||||
}
|
||||
30
pkg/dcimgui/ext.cpp
Normal file
30
pkg/dcimgui/ext.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#include "imgui.h"
|
||||
|
||||
// This file contains custom extensions for functionality that isn't
|
||||
// properly supported by Dear Bindings yet. Namely:
|
||||
// https://github.com/dearimgui/dear_bindings/issues/55
|
||||
|
||||
// Wrap this in a namespace to keep it separate from the C++ API
|
||||
namespace cimgui
|
||||
{
|
||||
#include "dcimgui.h"
|
||||
}
|
||||
|
||||
extern "C"
|
||||
{
|
||||
CIMGUI_API void ImFontConfig_ImFontConfig(cimgui::ImFontConfig* self)
|
||||
{
|
||||
static_assert(sizeof(cimgui::ImFontConfig) == sizeof(::ImFontConfig), "ImFontConfig size mismatch");
|
||||
static_assert(alignof(cimgui::ImFontConfig) == alignof(::ImFontConfig), "ImFontConfig alignment mismatch");
|
||||
::ImFontConfig defaults;
|
||||
*reinterpret_cast<::ImFontConfig*>(self) = defaults;
|
||||
}
|
||||
|
||||
CIMGUI_API void ImGuiStyle_ImGuiStyle(cimgui::ImGuiStyle* self)
|
||||
{
|
||||
static_assert(sizeof(cimgui::ImGuiStyle) == sizeof(::ImGuiStyle), "ImGuiStyle size mismatch");
|
||||
static_assert(alignof(cimgui::ImGuiStyle) == alignof(::ImGuiStyle), "ImGuiStyle alignment mismatch");
|
||||
::ImGuiStyle defaults;
|
||||
*reinterpret_cast<::ImGuiStyle*>(self) = defaults;
|
||||
}
|
||||
}
|
||||
43
pkg/dcimgui/main.zig
Normal file
43
pkg/dcimgui/main.zig
Normal file
@@ -0,0 +1,43 @@
|
||||
pub const build_options = @import("build_options");
|
||||
|
||||
pub const c = @cImport({
|
||||
// This is set during the build so it also has to be set
|
||||
// during import time to get the right types. Without this
|
||||
// you get stack size mismatches on some structs.
|
||||
@cDefine("IMGUI_USE_WCHAR32", "1");
|
||||
@cInclude("dcimgui.h");
|
||||
});
|
||||
|
||||
// OpenGL3 backend
|
||||
pub extern fn ImGui_ImplOpenGL3_Init(glsl_version: ?[*:0]const u8) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOpenGL3_RenderDrawData(draw_data: *c.ImDrawData) callconv(.c) void;
|
||||
|
||||
// Metal backend
|
||||
pub extern fn ImGui_ImplMetal_Init(device: *anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_NewFrame(render_pass_descriptor: *anyopaque) callconv(.c) void;
|
||||
pub extern fn ImGui_ImplMetal_RenderDrawData(draw_data: *c.ImDrawData, command_buffer: *anyopaque, command_encoder: *anyopaque) callconv(.c) void;
|
||||
|
||||
// OSX
|
||||
pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool;
|
||||
pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void;
|
||||
pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void;
|
||||
|
||||
// Internal API functions from dcimgui_internal.h
|
||||
// We declare these manually because the internal header contains bitfields
|
||||
// that Zig's cImport cannot translate.
|
||||
pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void;
|
||||
pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID;
|
||||
pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void;
|
||||
|
||||
// Extension functions from ext.cpp
|
||||
pub const ext = struct {
|
||||
pub extern fn ImFontConfig_ImFontConfig(self: *c.ImFontConfig) callconv(.c) void;
|
||||
pub extern fn ImGuiStyle_ImGuiStyle(self: *c.ImGuiStyle) callconv(.c) void;
|
||||
};
|
||||
|
||||
test {
|
||||
_ = c;
|
||||
}
|
||||
@@ -90,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
190
src/Surface.zig
190
src/Surface.zig
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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, ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
\\
|
||||
;
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ pub const SplitTree = split_tree.SplitTree;
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
|
||||
_ = @import("comparison.zig");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
24
src/font/res/BSD-2-Clause.txt
vendored
Normal 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.
|
||||
6
src/font/res/README.md
vendored
6
src/font/res/README.md
vendored
@@ -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
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
BIN
src/font/res/spleen-8x16.otb
vendored
Normal file
Binary file not shown.
BIN
src/font/res/spleen-8x16.pcf
vendored
Normal file
BIN
src/font/res/spleen-8x16.pcf
vendored
Normal file
Binary file not shown.
@@ -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
Reference in New Issue
Block a user