Merge branch 'main' into cleanup-action-binding-docs

This commit is contained in:
Mitchell Hashimoto
2025-01-24 15:33:39 -08:00
committed by GitHub
177 changed files with 9439 additions and 4353 deletions

View File

@ -1,6 +1,31 @@
on: [push, pull_request] on: [push, pull_request]
name: Nix name: Nix
jobs: jobs:
required:
name: "Required Checks: Nix"
runs-on: namespace-profile-ghostty-sm
needs:
- check-zig-cache-hash
steps:
- id: status
name: Determine status
run: |
results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}')
if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then
result="failed"
else
result="success"
fi
{
echo "result=${result}"
echo "results=${results}"
} | tee -a "$GITHUB_OUTPUT"
- if: always() && steps.status.outputs.result != 'success'
name: Check for failed status
run: |
echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}"
exit 1
check-zig-cache-hash: check-zig-cache-hash:
if: github.repository == 'ghostty-org/ghostty' if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-sm runs-on: namespace-profile-ghostty-sm

View File

@ -6,6 +6,45 @@ on:
name: Test name: Test
jobs: jobs:
required:
name: "Required Checks: Test"
runs-on: namespace-profile-ghostty-sm
needs:
- build
- build-bench
- build-linux-libghostty
- build-nix
- build-macos
- build-macos-matrix
- build-windows
- test
- test-gtk
- test-sentry-linux
- test-macos
- prettier
- alejandra
- typos
- test-pkg-linux
steps:
- id: status
name: Determine status
run: |
results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}')
if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then
result="failed"
else
result="success"
fi
{
echo "result=${result}"
echo "results=${results}"
} | tee -a "$GITHUB_OUTPUT"
- if: always() && steps.status.outputs.result != 'success'
name: Check for failed status
run: |
echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}"
exit 1
build: build:
strategy: strategy:
fail-fast: false fail-fast: false
@ -247,10 +286,10 @@ jobs:
run: | run: |
# Get the zig version from build.zig so that it only needs to be updated # Get the zig version from build.zig so that it only needs to be updated
$fileContent = Get-Content -Path "build.zig" -Raw $fileContent = Get-Content -Path "build.zig" -Raw
$pattern = 'const required_zig = "(.*?)";' $pattern = 'buildpkg\.requireZig\("(.*?)"\);'
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
Write-Output $version
$version = "zig-windows-x86_64-$zigVersion" $version = "zig-windows-x86_64-$zigVersion"
Write-Output $version
$uri = "https://ziglang.org/download/$zigVersion/$version.zip" $uri = "https://ziglang.org/download/$zigVersion/$version.zip"
Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip" Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip"
Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force
@ -342,7 +381,8 @@ jobs:
matrix: matrix:
adwaita: ["true", "false"] adwaita: ["true", "false"]
x11: ["true", "false"] x11: ["true", "false"]
name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} wayland: ["true", "false"]
name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }}
runs-on: namespace-profile-ghostty-sm runs-on: namespace-profile-ghostty-sm
needs: test needs: test
env: env:
@ -374,7 +414,8 @@ jobs:
zig build \ zig build \
-Dapp-runtime=gtk \ -Dapp-runtime=gtk \
-Dgtk-adwaita=${{ matrix.adwaita }} \ -Dgtk-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }} -Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-sentry-linux: test-sentry-linux:
strategy: strategy:

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ test/cases/**/*.actual.png
glad.zip glad.zip
/Box_test.ppm /Box_test.ppm
/Box_test_diff.ppm /Box_test_diff.ppm
/ghostty.qcow2

View File

@ -77,3 +77,100 @@ pull request will be accepted with a high degree of certainty.
> **Pull requests are NOT a place to discuss feature design.** Please do > **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion > not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch. > and link to your branch.
## Nix Virtual Machines
Several Nix virtual machine definitions are provided by the project for testing
and developing Ghostty against multiple different Linux desktop environments.
Running these requires a working Nix installation, either Nix on your
favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further
requirements for macOS are detailed below.
VMs should only be run on your local desktop and then powered off when not in
use, which will discard any changes to the VM.
The VM definitions provide minimal software "out of the box" but additional
software can be installed by using standard Nix mechanisms like `nix run nixpkgs#<package>`.
### Linux
1. Check out the Ghostty source and change to the directory.
2. Run `nix run .#<vmtype>`. `<vmtype>` can be any of the VMs defined in the
`nix/vm` directory (without the `.nix` suffix) excluding any file prefixed
with `common` or `create`.
3. The VM will build and then launch. Depending on the speed of your system, this
can take a while, but eventually you should get a new VM window.
4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending
on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be
writable by the VM user, so be careful!
### macOS
1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin`
config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your
configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/)
blog post for more information about the Linux builder and how to tune the performance.
2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions
above to launch a VM.
### Custom VMs
To easily create a custom VM without modifying the Ghostty source, create a new
directory, then create a file called `flake.nix` with the following text in the
new directory.
```
{
inputs = {
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
ghostty.url = "github:ghostty-org/ghostty";
};
outputs = {
nixpkgs,
ghostty,
...
}: {
nixosConfigurations.custom-vm = ghostty.create-gnome-vm {
nixpkgs = nixpkgs;
system = "x86_64-linux";
overlay = ghostty.overlays.releasefast;
# module = ./configuration.nix # also works
module = {pkgs, ...}: {
environment.systemPackages = [
pkgs.btop
];
};
};
};
}
```
The custom VM can then be run with a command like this:
```
nix run .#nixosConfigurations.custom-vm.config.system.build.vm
```
A file named `ghostty.qcow2` will be created that is used to persist any changes
made in the VM. To "reset" the VM to default delete the file and it will be
recreated the next time you run the VM.
### Contributing new VM definitions
#### VM Acceptance Criteria
We welcome the contribution of new VM definitions, as long as they meet the following criteria:
1. The should be different enough from existing VM definitions that they represent a distinct
user (and developer) experience.
2. There's a significant Ghostty user population that uses a similar environment.
3. The VMs can be built using only packages from the current stable NixOS release.
#### VM Definition Criteria
1. VMs should be as minimal as possible so that they build and launch quickly.
Additional software can be added at runtime with a command like `nix run nixpkgs#<package name>`.
2. VMs should not expose any services to the network, or run any remote access
software like SSH daemons, VNC or RDP.
3. VMs should auto-login using the "ghostty" user.

1879
build.zig

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,22 @@
.dependencies = .{ .dependencies = .{
// Zig libs // Zig libs
.libxev = .{ .libxev = .{
.url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz", .url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz",
.hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf", .hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c",
}, },
.mach_glfw = .{ .mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
.hash = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62", .hash = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62",
.lazy = true, .lazy = true,
}, },
.vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",
.hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f",
},
.z2d = .{
.url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a",
.hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a",
},
.zig_objc = .{ .zig_objc = .{
.url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz", .url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz",
.hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634", .hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634",
@ -25,6 +33,14 @@
.url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz", .url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz",
.hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25",
}, },
.zig_wayland = .{
.url = "https://codeberg.org/ifreund/zig-wayland/archive/fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz",
.hash = "12209ca054cb1919fa276e328967f10b253f7537c4136eb48f3332b0f7cf661cad38",
},
.zf = .{
.url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd",
.hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8",
},
// C libs // C libs
.cimgui = .{ .path = "./pkg/cimgui" }, .cimgui = .{ .path = "./pkg/cimgui" },
@ -46,23 +62,25 @@
.glslang = .{ .path = "./pkg/glslang" }, .glslang = .{ .path = "./pkg/glslang" },
.spirv_cross = .{ .path = "./pkg/spirv-cross" }, .spirv_cross = .{ .path = "./pkg/spirv-cross" },
// Wayland
.wayland = .{
.url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz",
.hash = "12202cdac858abc52413a6c6711d5026d2d3c8e13f95ca2c327eade0736298bb021f",
},
.wayland_protocols = .{
.url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz",
.hash = "12201a57c6ce0001aa034fa80fba3e1cd2253c560a45748f4f4dd21ff23b491cddef",
},
.plasma_wayland_protocols = .{
.url = "git+https://github.com/KDE/plasma-wayland-protocols?ref=main#db525e8f9da548cffa2ac77618dd0fbe7f511b86",
.hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566",
},
// Other // Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" }, .apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{ .iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/0e23daf59234fc892cba949562d7bf69204594bb.tar.gz",
.hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", .hash = "12204fc99743d8232e691ac22e058519bfc6ea92de4a11c6dba59b117531c847cd6a",
},
.vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",
.hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f",
},
.zf = .{
.url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd",
.hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8",
},
.z2d = .{
.url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a",
.hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a",
}, },
}, },
} }

View File

@ -1,12 +0,0 @@
//! Reverse Index (RI) - ESC M
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("A\nB\nC", .{});
try stdout.print("\x1BM", .{});
try stdout.print("D\n\n", .{});
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -1,23 +0,0 @@
//! Reverse Index (RI) - ESC M
//! Case: test that if the cursor is at the top, it scrolls down.
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("A\nB\n\n", .{});
try stdout.print("\x1B[H", .{}); // Top-left
try stdout.print("\x1BM", .{}); // Reverse-Index
try stdout.print("D", .{});
try stdout.print("\x0D", .{}); // CR
try stdout.print("\x0A", .{}); // LF
try stdout.print("\x1B[H", .{}); // Top-left
try stdout.print("\x1BM", .{}); // Reverse-Index
try stdout.print("E", .{});
try stdout.print("\n", .{});
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -1,99 +0,0 @@
//! Outputs various box glyphs for testing.
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// Box Drawing
{
try stdout.print("\x1b[4mBox Drawing\x1b[0m\n", .{});
var i: usize = 0x2500;
const step: usize = 32;
while (i <= 0x257F) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
try stdout.print("{u} ", .{@as(u21, @intCast(i + j))});
}
try stdout.print("\n\n", .{});
}
}
// Block Elements
{
try stdout.print("\x1b[4mBlock Elements\x1b[0m\n", .{});
var i: usize = 0x2580;
const step: usize = 32;
while (i <= 0x259f) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
try stdout.print("{u} ", .{@as(u21, @intCast(i + j))});
}
try stdout.print("\n\n", .{});
}
}
// Braille Elements
{
try stdout.print("\x1b[4mBraille\x1b[0m\n", .{});
var i: usize = 0x2800;
const step: usize = 32;
while (i <= 0x28FF) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
try stdout.print("{u} ", .{@as(u21, @intCast(i + j))});
}
try stdout.print("\n\n", .{});
}
}
{
try stdout.print("\x1b[4mSextants\x1b[0m\n", .{});
var i: usize = 0x1FB00;
const step: usize = 32;
const end = 0x1FB3B;
while (i <= end) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
const v = i + j;
if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))});
}
try stdout.print("\n\n", .{});
}
}
{
try stdout.print("\x1b[4mWedge Triangles\x1b[0m\n", .{});
var i: usize = 0x1FB3C;
const step: usize = 32;
const end = 0x1FB6B;
while (i <= end) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
const v = i + j;
if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))});
}
try stdout.print("\n\n", .{});
}
}
{
try stdout.print("\x1b[4mOther\x1b[0m\n", .{});
var i: usize = 0x1FB70;
const step: usize = 32;
const end = 0x1FB8B;
while (i <= end) : (i += step) {
var j: usize = 0;
while (j < step) : (j += 1) {
const v = i + j;
if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))});
}
try stdout.print("\n\n", .{});
}
}
}

View File

@ -1,15 +0,0 @@
//! Set Top and Bottom Margins (DECSTBM) - ESC [ r
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("A\nB\nC\nD", .{});
try stdout.print("\x1B[1;3r", .{}); // cursor up
try stdout.print("\x1B[1;1H", .{}); // top-left
try stdout.print("\x1B[M", .{}); // delete line
try stdout.print("E\n", .{});
try stdout.print("\x1B[7;1H", .{}); // cursor up
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -1,14 +0,0 @@
//! Delete Line (DL) - Esc [ M
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("A\nB\nC\nD", .{});
try stdout.print("\x1B[2A", .{}); // cursor up
try stdout.print("\x1B[M", .{});
try stdout.print("E\n", .{});
try stdout.print("\x1B[B", .{});
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -1,17 +0,0 @@
//! Insert Line (IL) - Esc [ L
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("\x1B[2J", .{}); // clear screen
try stdout.print("\x1B[1;1H", .{}); // set cursor position
try stdout.print("A\nB\nC\nD\nE", .{});
try stdout.print("\x1B[1;2r", .{}); // set scroll region
try stdout.print("\x1B[1;1H", .{}); // set cursor position
try stdout.print("\x1B[1L", .{}); // insert lines
try stdout.print("X", .{});
try stdout.print("\x1B[7;1H", .{}); // set cursor position
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -1,10 +0,0 @@
//! DECALN - ESC # 8
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("\x1B#8", .{});
// const stdin = std.io.getStdIn().reader();
// _ = try stdin.readByte();
}

View File

@ -7,6 +7,7 @@ Icon=com.mitchellh.ghostty
Categories=System;TerminalEmulator; Categories=System;TerminalEmulator;
Keywords=terminal;tty;pty; Keywords=terminal;tty;pty;
StartupNotify=true StartupNotify=true
StartupWMClass=com.mitchellh.ghostty
Terminal=false Terminal=false
Actions=new-window; Actions=new-window;
X-GNOME-UsesNotifications=true X-GNOME-UsesNotifications=true

0
dist/linux/ghostty_dolphin.desktop vendored Normal file → Executable file
View File

97
dist/linux/ghostty_nautilus.py vendored Normal file
View File

@ -0,0 +1,97 @@
# Adapted from wezterm: https://github.com/wez/wezterm/blob/main/assets/wezterm-nautilus.py
# original copyright notice:
#
# Copyright (C) 2022 Sebastian Wiesner <sebastian@swsnr.de>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# 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
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):
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)
def _menu_item_activated(self, _menu, paths):
for path in paths:
self._open_terminal(path)
def _make_item(self, name, paths):
item = Nautilus.MenuItem(name=name, label='Open in Ghostty',
icon='com.mitchellh.ghostty')
item.connect('activate', self._menu_item_activated, paths)
return item
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 []
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 []

View File

@ -31,38 +31,81 @@
zig, zig,
... ...
}: }:
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
pkgs-stable = nixpkgs-stable.legacyPackages.${system}; builtins.map (
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; system: let
in { pkgs-stable = nixpkgs-stable.legacyPackages.${system};
devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
zig = zig.packages.${system}."0.13.0"; in {
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
}; zig = zig.packages.${system}."0.13.0";
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
};
packages.${system} = let packages.${system} = let
mkArgs = optimize: { mkArgs = optimize: {
inherit optimize; inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty"; revision = self.shortRev or self.dirtyShortRev or "dirty";
}; };
in rec { in rec {
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast; ghostty = ghostty-releasefast;
default = ghostty; default = ghostty;
}; };
formatter.${system} = pkgs-stable.alejandra; formatter.${system} = pkgs-stable.alejandra;
# Our supported systems are the same supported systems as the Zig binaries. apps.${system} = let
}) (builtins.attrNames zig.packages)) runVM = (
module: let
vm = import ./nix/vm/create.nix {
inherit system module;
nixpkgs = nixpkgs-stable;
overlay = self.overlays.debug;
};
program = pkgs-stable.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
${pkgs-stable.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
program = "${program}";
}
);
in {
wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix;
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;
};
}
# Our supported systems are the same supported systems as the Zig binaries.
) (builtins.attrNames zig.packages)
)
// { // {
overlays.default = final: prev: { overlays = {
ghostty = self.packages.${prev.system}.default; default = self.overlays.releasefast;
releasefast = final: prev: {
ghostty = self.packages.${prev.system}.ghostty-releasefast;
};
debug = final: prev: {
ghostty = self.packages.${prev.system}.ghostty-debug;
};
}; };
create-vm = import ./nix/vm/create.nix;
create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix;
create-gnome-vm = import ./nix/vm/create-gnome.nix;
create-plasma6-vm = import ./nix/vm/create-plasma6.nix;
create-xfce-vm = import ./nix/vm/create-xfce.nix;
}; };
nixConfig = { nixConfig = {

View File

@ -159,7 +159,7 @@ typedef enum {
GHOSTTY_KEY_EQUAL, GHOSTTY_KEY_EQUAL,
GHOSTTY_KEY_LEFT_BRACKET, // [ GHOSTTY_KEY_LEFT_BRACKET, // [
GHOSTTY_KEY_RIGHT_BRACKET, // ] GHOSTTY_KEY_RIGHT_BRACKET, // ]
GHOSTTY_KEY_BACKSLASH, // / GHOSTTY_KEY_BACKSLASH, // \
// control // control
GHOSTTY_KEY_UP, GHOSTTY_KEY_UP,
@ -559,10 +559,13 @@ typedef struct {
// apprt.Action.Key // apprt.Action.Key
typedef enum { typedef enum {
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB, GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
@ -681,10 +684,11 @@ void ghostty_config_open();
ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
ghostty_config_t); ghostty_config_t);
void ghostty_app_free(ghostty_app_t); void ghostty_app_free(ghostty_app_t);
bool ghostty_app_tick(ghostty_app_t); void ghostty_app_tick(ghostty_app_t);
void* ghostty_app_userdata(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t);
void ghostty_app_set_focus(ghostty_app_t, bool); void ghostty_app_set_focus(ghostty_app_t, bool);
bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_keyboard_changed(ghostty_app_t);
void ghostty_app_open_config(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t);
void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
@ -712,7 +716,8 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
ghostty_color_scheme_e); ghostty_color_scheme_e);
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e); ghostty_input_mods_e);
void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_captured(ghostty_surface_t);
bool ghostty_surface_mouse_button(ghostty_surface_t, bool ghostty_surface_mouse_button(ghostty_surface_t,

View File

@ -10,8 +10,8 @@
29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@ -69,8 +69,12 @@
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; };
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; };
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; };
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
@ -87,6 +91,8 @@
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; };
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; };
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; };
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; };
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; };
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; };
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
@ -99,6 +105,7 @@
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; }; C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; };
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; };
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; }; FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; };
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -108,8 +115,8 @@
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; };
@ -158,10 +165,14 @@
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; }; A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = "<group>"; };
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = "<group>"; };
A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; }; A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = "<group>"; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = "<group>"; };
@ -177,6 +188,8 @@
A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; };
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; };
A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = "<group>"; };
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Event.swift; sourceTree = "<group>"; };
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = "<group>"; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = "<group>"; };
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = "<group>"; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = "<group>"; };
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -192,6 +205,7 @@
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = "<group>"; }; C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = "<group>"; };
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; }; C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; };
C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = "<group>"; }; C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = "<group>"; };
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = "<group>"; };
FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; }; FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; };
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -261,18 +275,22 @@
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
A5A2A3C92D4445E20033CF96 /* Dock.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
A5CEAFDA29B8005900646FDA /* SplitView */, A5CEAFDA29B8005900646FDA /* SplitView */,
@ -351,12 +369,14 @@
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */, A55685DF29A03A9F004303CE /* AppError.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
); );
path = Ghostty; path = Ghostty;
sourceTree = "<group>"; sourceTree = "<group>";
@ -399,13 +419,13 @@
children = ( children = (
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */, FC9ABA9B2D0F538D0020D4C8 /* bash-completion */,
29C15B1C2CDC3B2000520DD4 /* bat */, 29C15B1C2CDC3B2000520DD4 /* bat */,
55154BDF2B33911F001622DC /* ghostty */,
552964E52B34A9B400030505 /* vim */,
A586167B2B7703CC009BDB1D /* fish */, A586167B2B7703CC009BDB1D /* fish */,
55154BDF2B33911F001622DC /* ghostty */,
A5985CE52C33060F00C57AD3 /* man */, A5985CE52C33060F00C57AD3 /* man */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
FC5218F92D10FFC7004C93E0 /* zsh */,
9351BE8E2D22937F003B3499 /* nvim */, 9351BE8E2D22937F003B3499 /* nvim */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
552964E52B34A9B400030505 /* vim */,
FC5218F92D10FFC7004C93E0 /* zsh */,
); );
name = Resources; name = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -439,6 +459,7 @@
children = ( children = (
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
@ -607,16 +628,20 @@
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@ -632,12 +657,14 @@
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
@ -647,6 +674,7 @@
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */,
@ -765,21 +793,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@ -934,21 +963,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@ -987,21 +1017,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",

View File

@ -30,11 +30,13 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSplitRight: NSMenuItem? @IBOutlet private var menuSplitRight: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem? @IBOutlet private var menuSplitDown: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem? @IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseTab: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem?
@ -90,10 +92,8 @@ class AppDelegate: NSObject,
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
} }
/// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually /// Tracks the windows that we hid for toggleVisibility.
/// brings each window one by one to the front. But at worst its off by one set of toggles and this private var hiddenWindows: [Weak<NSWindow>] = []
/// makes our logic very easy.
private var isVisible: Bool = true
/// The observer for the app appearance. /// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil private var appearanceObserver: NSKeyValueObservation? = nil
@ -217,15 +217,20 @@ class AppDelegate: NSObject,
} }
func applicationDidBecomeActive(_ notification: Notification) { func applicationDidBecomeActive(_ notification: Notification) {
guard !applicationHasBecomeActive else { return } // If we're back then clear the hidden windows
applicationHasBecomeActive = true self.hiddenWindows = []
// Let's launch our first window. We only do this if we have no other windows. It // First launch stuff
// is possible to have other windows in a few scenarios: if (!applicationHasBecomeActive) {
// - if we're opening a URL since `application(_:openFile:)` is called before this. applicationHasBecomeActive = true
// - if we're restoring from persisted state
if terminalManager.windows.count == 0 && derivedConfig.initialWindow { // Let's launch our first window. We only do this if we have no other windows. It
terminalManager.newWindow() // is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state
if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
terminalManager.newWindow()
}
} }
} }
@ -346,6 +351,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
@ -353,6 +359,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
@ -425,6 +432,22 @@ class AppDelegate: NSObject,
// because we let it capture and propagate. // because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event } guard NSApp.mainWindow == nil else { return event }
// If this event as-is would result in a key binding then we send it.
if let app = ghostty.app,
ghostty_app_key_is_binding(
app,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
// If the key was handled by Ghostty we stop the event chain. If
// the key wasn't handled then we let it fall through and continue
// processing. This is important because some bindings may have no
// affect at this scope.
if (ghostty_app_key(
app,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
return nil
}
}
// If this event would be handled by our menu then we do nothing. // If this event would be handled by our menu then we do nothing.
if let mainMenu = NSApp.mainMenu, if let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) { mainMenu.performKeyEquivalent(with: event) {
@ -438,13 +461,7 @@ class AppDelegate: NSObject,
guard let ghostty = self.ghostty.app else { return event } guard let ghostty = self.ghostty.app else { return event }
// Build our event input and call ghostty // Build our event input and call ghostty
var key_ev = ghostty_input_key_s() if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
key_ev.action = GHOSTTY_ACTION_PRESS
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
if (ghostty_app_key(ghostty, key_ev)) {
// The key was used so we want to stop it from going to our Mac app // The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event)") Ghostty.logger.debug("local key event handled event=\(event)")
return nil return nil
@ -692,21 +709,23 @@ class AppDelegate: NSObject,
/// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application
@IBAction func toggleVisibility(_ sender: Any) { @IBAction func toggleVisibility(_ sender: Any) {
// We only care about terminal windows. // If we have focus, then we hide all windows.
for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { if NSApp.isActive {
if isVisible { // We need to keep track of the windows that were visible because we only
window.orderOut(nil) // want to bring back these windows if we remove the toggle.
} else { self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) }
window.makeKeyAndOrderFront(nil) NSApp.hide(nil)
} return
} }
// After bringing them all to front we make sure our app is active too. // If we're not active, we want to become active
if !isVisible { NSApp.activate(ignoringOtherApps: true)
NSApp.activate(ignoringOtherApps: true)
}
isVisible.toggle() // Bring all windows to the front. Note: we don't use NSApp.unhide because
// that will unhide ALL hidden windows. We want to only bring forward the
// ones that we hid.
self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() }
self.hiddenWindows = []
} }
private struct DerivedConfig { private struct DerivedConfig {

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct"> <document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
</dependencies> </dependencies>
<objects> <objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"> <customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -17,6 +17,7 @@
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/> <outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
<outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/> <outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
<outlet property="menuCloseAllWindows" destination="yKr-Vi-Yqw" id="Zet-Ir-zbm"/> <outlet property="menuCloseAllWindows" destination="yKr-Vi-Yqw" id="Zet-Ir-zbm"/>
<outlet property="menuCloseTab" destination="Obb-Mk-j8J" id="Gda-L0-gdz"/>
<outlet property="menuCloseWindow" destination="W5w-UZ-crk" id="6ff-BT-ENV"/> <outlet property="menuCloseWindow" destination="W5w-UZ-crk" id="6ff-BT-ENV"/>
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/> <outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/> <outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
@ -31,6 +32,7 @@
<outlet property="menuNextSplit" destination="bD7-ei-wKU" id="LeT-xw-eh4"/> <outlet property="menuNextSplit" destination="bD7-ei-wKU" id="LeT-xw-eh4"/>
<outlet property="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/> <outlet property="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/>
<outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/> <outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/>
<outlet property="menuPasteSelection" destination="akq-ov-Jjh" id="GS8-aQ-hVw"/>
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/> <outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/> <outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/> <outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
@ -154,6 +156,12 @@
<action selector="close:" target="-1" id="tTZ-2b-Mbm"/> <action selector="close:" target="-1" id="tTZ-2b-Mbm"/>
</connections> </connections>
</menuItem> </menuItem>
<menuItem title="Close Tab" id="Obb-Mk-j8J">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="closeTab:" target="-1" id="UBb-Bd-nkj"/>
</connections>
</menuItem>
<menuItem title="Close Window" id="W5w-UZ-crk"> <menuItem title="Close Window" id="W5w-UZ-crk">
<modifierMask key="keyEquivalentModifierMask"/> <modifierMask key="keyEquivalentModifierMask"/>
<connections> <connections>
@ -185,6 +193,12 @@
<action selector="paste:" target="-1" id="ZKe-2B-mel"/> <action selector="paste:" target="-1" id="ZKe-2B-mel"/>
</connections> </connections>
</menuItem> </menuItem>
<menuItem title="Paste Selection" id="akq-ov-Jjh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="pasteSelection:" target="-1" id="vo3-Rf-Udb"/>
</connections>
</menuItem>
<menuItem title="Select All" id="q2h-lq-e4r"> <menuItem title="Select All" id="q2h-lq-e4r">
<modifierMask key="keyEquivalentModifierMask"/> <modifierMask key="keyEquivalentModifierMask"/>
<connections> <connections>

View File

@ -3,6 +3,12 @@ import Cocoa
import SwiftUI import SwiftUI
import GhosttyKit import GhosttyKit
// This is a Apple's private function that we need to call to get the active space.
@_silgen_name("CGSGetActiveSpace")
func CGSGetActiveSpace(_ cid: Int) -> size_t
@_silgen_name("CGSMainConnectionID")
func CGSMainConnectionID() -> Int
/// Controller for the "quick" terminal. /// Controller for the "quick" terminal.
class QuickTerminalController: BaseTerminalController { class QuickTerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "QuickTerminal" } override var windowNibName: NSNib.Name? { "QuickTerminal" }
@ -18,6 +24,13 @@ class QuickTerminalController: BaseTerminalController {
/// application to the front. /// application to the front.
private var previousApp: NSRunningApplication? = nil private var previousApp: NSRunningApplication? = nil
// The active space when the quick terminal was last shown.
private var previousActiveSpace: size_t = 0
/// This is set to true of the dock was autohid when the terminal animated in. This lets us
/// know if we have to unhide when the terminal is animated out.
private var hidDock: Bool = false
/// The configuration derived from the Ghostty config so we don't need to rely on references. /// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig private var derivedConfig: DerivedConfig
@ -107,8 +120,28 @@ class QuickTerminalController: BaseTerminalController {
self.previousApp = nil self.previousApp = nil
} }
if (derivedConfig.quickTerminalAutoHide) { if derivedConfig.quickTerminalAutoHide {
animateOut() switch derivedConfig.quickTerminalSpaceBehavior {
case .remain:
// If we lose focus on the active space, then we can animate out
animateOut()
case .move:
let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID())
if previousActiveSpace == currentActiveSpace {
// We haven't moved spaces. We lost focus to another app on the
// current space. Animate out.
animateOut()
} else {
// We've moved to a different space. Bring the quick terminal back
// into view.
DispatchQueue.main.async {
self.window?.makeKeyAndOrderFront(nil)
}
self.previousActiveSpace = currentActiveSpace
}
}
} }
} }
@ -163,6 +196,9 @@ class QuickTerminalController: BaseTerminalController {
} }
} }
// Set previous active space
self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID())
// Animate the window in // Animate the window in
animateWindowIn(window: window, from: position) animateWindowIn(window: window, from: position)
@ -192,14 +228,39 @@ class QuickTerminalController: BaseTerminalController {
animateWindowOut(window: window, to: position) animateWindowOut(window: window, to: position)
} }
private func hideDock() {
guard !hidDock else { return }
NSApp.acquirePresentationOption(.autoHideDock)
hidDock = true
}
private func unhideDock() {
guard hidDock else { return }
NSApp.releasePresentationOption(.autoHideDock)
hidDock = false
}
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return } guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Move our window off screen to the top // Move our window off screen to the top
position.setInitial(in: window, on: screen) position.setInitial(in: window, on: screen)
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
// Move it to the visible position since animation requires this // Move it to the visible position since animation requires this
window.makeKeyAndOrderFront(nil) DispatchQueue.main.async {
window.makeKeyAndOrderFront(nil)
}
// If our dock position would conflict with our target location then
// we autohide the dock.
if position.conflictsWithDock(on: screen) {
hideDock()
}
// Run the animation that moves our window into the proper place and makes // Run the animation that moves our window into the proper place and makes
// it visible. // it visible.
@ -211,8 +272,16 @@ class QuickTerminalController: BaseTerminalController {
// There is a very minor delay here so waiting at least an event loop tick // There is a very minor delay here so waiting at least an event loop tick
// keeps us safe from the view not being on the window. // keeps us safe from the view not being on the window.
DispatchQueue.main.async { DispatchQueue.main.async {
// If we canceled our animation in we do nothing // If we canceled our animation clean up some state.
guard self.visible else { return } guard self.visible else {
self.unhideDock()
return
}
// After animating in, we reset the window level to a value that
// is above other windows but not as high as popUpMenu. This allows
// things like IME dropdowns to appear properly.
window.level = .floating
// Now that the window is visible, sync our appearance. This function // Now that the window is visible, sync our appearance. This function
// requires the window is visible. // requires the window is visible.
@ -276,6 +345,17 @@ class QuickTerminalController: BaseTerminalController {
} }
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// If we hid the dock then we unhide it.
unhideDock()
// If the window isn't on our active space then we don't animate, we just
// hide it.
if !window.isOnActiveSpace {
self.previousApp = nil
window.orderOut(self)
return
}
// We always animate out to whatever screen the window is actually on. // We always animate out to whatever screen the window is actually on.
guard let screen = window.screen ?? NSScreen.main else { return } guard let screen = window.screen ?? NSScreen.main else { return }
@ -297,6 +377,11 @@ class QuickTerminalController: BaseTerminalController {
} }
} }
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = derivedConfig.quickTerminalAnimationDuration context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn) context.timingFunction = .init(name: .easeIn)
@ -311,23 +396,13 @@ class QuickTerminalController: BaseTerminalController {
private func syncAppearance() { private func syncAppearance() {
guard let window else { return } guard let window else { return }
// Change the collection behavior of the window depending on the configuration.
window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior
// If our window is not visible, then no need to sync the appearance yet. // If our window is not visible, then no need to sync the appearance yet.
// Some APIs such as window blur have no effect unless the window is visible. // Some APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { return } guard window.isVisible else { return }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch (self.derivedConfig.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (self.derivedConfig.backgroundOpacity < 1) { if (self.derivedConfig.backgroundOpacity < 1) {
window.isOpaque = false window.isOpaque = false
@ -396,14 +471,14 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalScreen: QuickTerminalScreen let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool let quickTerminalAutoHide: Bool
let windowColorspace: String let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let backgroundOpacity: Double let backgroundOpacity: Double
init() { init() {
self.quickTerminalScreen = .main self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true self.quickTerminalAutoHide = true
self.windowColorspace = "" self.quickTerminalSpaceBehavior = .move
self.backgroundOpacity = 1.0 self.backgroundOpacity = 1.0
} }
@ -411,7 +486,7 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.windowColorspace = config.windowColorspace self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.backgroundOpacity = config.backgroundOpacity self.backgroundOpacity = config.backgroundOpacity
} }
} }

View File

@ -69,7 +69,7 @@ enum QuickTerminalPosition : String {
finalSize.width = screen.frame.width finalSize.width = screen.frame.width
case .left, .right: case .left, .right:
finalSize.height = screen.frame.height finalSize.height = screen.visibleFrame.height
case .center: case .center:
finalSize.width = screen.frame.width / 2 finalSize.width = screen.frame.width / 2
@ -89,13 +89,13 @@ enum QuickTerminalPosition : String {
return .init(x: screen.frame.minX, y: -window.frame.height) return .init(x: screen.frame.minX, y: -window.frame.height)
case .left: case .left:
return .init(x: -window.frame.width, y: 0) return .init(x: screen.frame.minX-window.frame.width, y: 0)
case .right: case .right:
return .init(x: screen.frame.maxX, y: 0) return .init(x: screen.frame.maxX, y: 0)
case .center: case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: screen.visibleFrame.maxY - window.frame.width) return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.height - window.frame.width)
} }
} }
@ -115,7 +115,25 @@ enum QuickTerminalPosition : String {
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y)
case .center: case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: (screen.visibleFrame.maxY - window.frame.height) / 2) return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
}
}
func conflictsWithDock(on screen: NSScreen) -> Bool {
// Screen must have a dock for it to conflict
guard screen.hasDock else { return false }
// Get the dock orientation for this screen
guard let orientation = Dock.orientation else { return false }
// Depending on the orientation of the dock, we conflict if our quick terminal
// would potentially "hit" the dock. In the future we should probably consider
// the frame of the quick terminal.
return switch (orientation) {
case .top: self == .top || self == .left || self == .right
case .bottom: self == .bottom || self == .left || self == .right
case .left: self == .top || self == .bottom
case .right: self == .top || self == .bottom
} }
} }
} }

View File

@ -0,0 +1,36 @@
import Foundation
import Cocoa
enum QuickTerminalSpaceBehavior {
case remain
case move
init?(fromGhosttyConfig string: String) {
switch (string) {
case "move":
self = .move
case "remain":
self = .remain
default:
return nil
}
}
var collectionBehavior: NSWindow.CollectionBehavior {
let commonBehavior: [NSWindow.CollectionBehavior] = [
.ignoresCycle,
.fullScreenAuxiliary
]
switch (self) {
case .move:
// We want this to move the window to the active space.
return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior)
case .remain:
// We want this to remain the window in the current space.
return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior)
}
}
}

View File

@ -1,6 +1,6 @@
import Cocoa import Cocoa
class QuickTerminalWindow: NSWindow { class QuickTerminalWindow: NSPanel {
// Both of these must be true for windows without decorations to be able to // Both of these must be true for windows without decorations to be able to
// still become key/main and receive events. // still become key/main and receive events.
override var canBecomeKey: Bool { return true } override var canBecomeKey: Bool { return true }
@ -26,22 +26,7 @@ class QuickTerminalWindow: NSWindow {
// window remains resizable. // window remains resizable.
self.styleMask.remove(.titled) self.styleMask.remove(.titled)
// We need to set our window level to a high value. In testing, only // We don't want to activate the owning app when quick terminal is triggered.
// popUpMenu and above do what we want. This gets it above the menu bar self.styleMask.insert(.nonactivatingPanel)
// and lets us render off screen.
self.level = .popUpMenu
// This plus the level above was what was needed for the animation to work,
// because it gets the window off screen properly. Plus we add some fields
// we just want the behavior of.
self.collectionBehavior = [
// We want this to be part of every space because it is a singleton.
.canJoinAllSpaces,
// We don't want to be part of command-tilde
.ignoresCycle,
// We never support fullscreen
.fullScreenNone]
} }
} }

View File

@ -389,9 +389,9 @@ class BaseTerminalController: NSWindowController,
} }
switch (request) { switch (request) {
case .osc_52_write: case let .osc_52_write(pasteboard):
guard case .confirm = action else { break } guard case .confirm = action else { break }
let pb = NSPasteboard.general let pb = pasteboard ?? NSPasteboard.general
pb.declareTypes([.string], owner: nil) pb.declareTypes([.string], owner: nil)
pb.setString(cc.contents, forType: .string) pb.setString(cc.contents, forType: .string)
case .osc_52_read, .paste: case .osc_52_read, .paste:
@ -452,6 +452,7 @@ class BaseTerminalController: NSWindowController,
self.alert = nil self.alert = nil
switch (response) { switch (response) {
case .alertFirstButtonReturn: case .alertFirstButtonReturn:
alert.window.orderOut(nil)
window.close() window.close()
default: default:

View File

@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController {
private var restorable: Bool = true private var restorable: Bool = true
/// The configuration derived from the Ghostty config so we don't need to rely on references. /// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig private(set) var derivedConfig: DerivedConfig
/// The notification cancellable for focused surface property changes. /// The notification cancellable for focused surface property changes.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = [] private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
@ -60,6 +60,11 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab), selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab, name: Ghostty.Notification.ghosttyGotoTab,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(onCloseTab),
name: .ghosttyCloseTab,
object: nil)
center.addObserver( center.addObserver(
self, self,
selector: #selector(ghosttyConfigDidChange(_:)), selector: #selector(ghosttyConfigDidChange(_:)),
@ -313,9 +318,9 @@ class TerminalController: BaseTerminalController {
// Full size content view so we can extend // Full size content view so we can extend
// content in to the hidden titlebar's area // content in to the hidden titlebar's area
.fullSizeContentView, .fullSizeContentView,
.resizable, .resizable,
.closable, .closable,
.miniaturizable, .miniaturizable,
] ]
@ -361,33 +366,31 @@ class TerminalController: BaseTerminalController {
// If window decorations are disabled, remove our title // If window decorations are disabled, remove our title
if (!config.windowDecorations) { window.styleMask.remove(.titled) } if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
// Ghostty defaults to sRGB but this can be overridden.
switch (config.windowColorspace) {
case "display-p3":
window.colorSpace = .displayP3
case "srgb":
fallthrough
default:
window.colorSpace = .sRGB
}
// If we have only a single surface (no splits) and that surface requested // If we have only a single surface (no splits) and that surface requested
// an initial size then we set it here now. // an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree { if case let .leaf(leaf) = surfaceTree {
if let initialSize = leaf.surface.initialSize, if let initialSize = leaf.surface.initialSize,
let screen = window.screen ?? NSScreen.main { let screen = window.screen ?? NSScreen.main {
// Setup our frame. We need to first subtract the views frame so that we can // Get the current frame of the window
// just get the chrome frame so that we only affect the surface view size.
var frame = window.frame var frame = window.frame
frame.size.width -= leaf.surface.frame.size.width
frame.size.height -= leaf.surface.frame.size.height
frame.size.width += min(initialSize.width, screen.frame.width)
frame.size.height += min(initialSize.height, screen.frame.height)
// We have no tabs and we are not a split, so set the initial size of the window. // Calculate the chrome size (window size minus view size)
let chromeWidth = frame.size.width - leaf.surface.frame.size.width
let chromeHeight = frame.size.height - leaf.surface.frame.size.height
// Calculate the new width and height, clamping to the screen's size
let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width)
let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height)
// Update the frame size while keeping the window's position intact
frame.size.width = newWidth
frame.size.height = newHeight
// Ensure the window doesn't go outside the screen boundaries
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
// Set the updated frame to the window
window.setFrame(frame, display: true) window.setFrame(frame, display: true)
} }
} }
@ -508,7 +511,50 @@ class TerminalController: BaseTerminalController {
ghostty.newTab(surface: surface) ghostty.newTab(surface: surface)
} }
@IBAction override func closeWindow(_ sender: Any) { private func confirmClose(
window: NSWindow,
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
completion()
}
}
}
@IBAction func closeTab(_ sender: Any?) {
guard let window = window else { return }
guard window.tabGroup != nil else {
// No tabs, no tab group, just perform a normal close.
window.performClose(sender)
return
}
if surfaceTree?.needsConfirmQuit() ?? false {
confirmClose(
window: window,
messageText: "Close Tab?",
informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
) {
window.close()
}
return
}
window.close()
}
@IBAction override func closeWindow(_ sender: Any?) {
guard let window = window else { return } guard let window = window else { return }
guard let tabGroup = window.tabGroup else { guard let tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close. // No tabs, no tab group, just perform a normal close.
@ -523,47 +569,34 @@ class TerminalController: BaseTerminalController {
} }
// Check if any windows require close confirmation. // Check if any windows require close confirmation.
var needsConfirm: Bool = false let needsConfirm = tabGroup.windows.contains { tabWindow in
for tabWindow in tabGroup.windows { guard let controller = tabWindow.windowController as? TerminalController else {
guard let c = tabWindow.windowController as? TerminalController else { continue } return false
if (c.surfaceTree?.needsConfirmQuit() ?? false) {
needsConfirm = true
break
} }
return controller.surfaceTree?.needsConfirmQuit() ?? false
} }
// If none need confirmation then we can just close all the windows. // If none need confirmation then we can just close all the windows.
if (!needsConfirm) { if !needsConfirm {
for tabWindow in tabGroup.windows { tabGroup.windows.forEach { $0.close() }
tabWindow.close()
}
return return
} }
// If we need confirmation by any, show one confirmation for all windows confirmClose(
// in the tab group. window: window,
let alert = NSAlert() messageText: "Close Window?",
alert.messageText = "Close Window?" informativeText: "All terminal sessions in this window will be terminated."
alert.informativeText = "All terminal sessions in this window will be terminated." ) {
alert.addButton(withTitle: "Close Window") tabGroup.windows.forEach { $0.close() }
alert.addButton(withTitle: "Cancel") }
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
if (response == .alertFirstButtonReturn) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
}
})
} }
@IBAction func toggleGhosttyFullScreen(_ sender: Any) { @IBAction func toggleGhosttyFullScreen(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return } guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface) ghostty.toggleFullscreen(surface: surface)
} }
@IBAction func toggleTerminalInspector(_ sender: Any) { @IBAction func toggleTerminalInspector(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return } guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface) ghostty.toggleTerminalInspector(surface: surface)
} }
@ -720,6 +753,12 @@ class TerminalController: BaseTerminalController {
targetWindow.makeKeyAndOrderFront(nil) targetWindow.makeKeyAndOrderFront(nil)
} }
@objc private func onCloseTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: target) ?? false else { return }
closeTab(self)
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) { @objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return } guard target == self.focusedSurface else { return }
@ -737,7 +776,7 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode) toggleFullscreen(mode: fullscreenMode)
} }
private struct DerivedConfig { struct DerivedConfig {
let backgroundColor: Color let backgroundColor: Color
let macosTitlebarStyle: String let macosTitlebarStyle: String

View File

@ -56,15 +56,10 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// The title for our window // The title for our window
private var title: String { private var title: String {
var title = "👻" if let surfaceTitle, !surfaceTitle.isEmpty {
return surfaceTitle
if let surfaceTitle = surfaceTitle {
if (surfaceTitle.count > 0) {
title = surfaceTitle
}
} }
return "👻"
return title
} }
// The pwd of the focused surface as a URL // The pwd of the focused surface as a URL

View File

@ -115,6 +115,21 @@ class TerminalWindow: NSWindow {
} }
} }
// We override this so that with the hidden titlebar style the titlebar
// area is not draggable.
override var contentLayoutRect: CGRect {
var rect = super.contentLayoutRect
// If we are using a hidden titlebar style, the content layout is the
// full frame making it so that it is not draggable.
if let controller = windowController as? TerminalController,
controller.derivedConfig.macosTitlebarStyle == "hidden" {
rect.origin.y = 0
rect.size.height = self.frame.height
}
return rect
}
// The window theme configuration from Ghostty. This is used to control some // The window theme configuration from Ghostty. This is used to control some
// behaviors that don't look quite right in certain situations. // behaviors that don't look quite right in certain situations.
var windowTheme: TerminalWindowTheme? var windowTheme: TerminalWindowTheme?
@ -667,12 +682,16 @@ fileprivate class WindowDragView: NSView {
// A view that matches the color of selected and unselected tabs in the adjacent tab bar. // A view that matches the color of selected and unselected tabs in the adjacent tab bar.
fileprivate class WindowButtonsBackdropView: NSView { fileprivate class WindowButtonsBackdropView: NSView {
private let terminalWindow: TerminalWindow // This must be weak because the window has this view. Otherwise
// a retain cycle occurs.
private weak var terminalWindow: TerminalWindow?
private let isLightTheme: Bool private let isLightTheme: Bool
private let overlayLayer = VibrantLayer() private let overlayLayer = VibrantLayer()
var isHighlighted: Bool = true { var isHighlighted: Bool = true {
didSet { didSet {
guard let terminalWindow else { return }
if isLightTheme { if isLightTheme {
overlayLayer.isHidden = isHighlighted overlayLayer.isHidden = isHighlighted
layer?.backgroundColor = .clear layer?.backgroundColor = .clear

View File

@ -62,7 +62,7 @@ extension Ghostty {
// uses to interface with the application runtime environment. // uses to interface with the application runtime environment.
var runtime_cfg = ghostty_runtime_config_s( var runtime_cfg = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(), userdata: Unmanaged.passUnretained(self).toOpaque(),
supports_selection_clipboard: false, supports_selection_clipboard: true,
wakeup_cb: { userdata in App.wakeup(userdata) }, wakeup_cb: { userdata in App.wakeup(userdata) },
action_cb: { app, target, action in App.action(app!, target: target, action: action) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) },
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
@ -117,23 +117,7 @@ extension Ghostty {
func appTick() { func appTick() {
guard let app = self.app else { return } guard let app = self.app else { return }
ghostty_app_tick(app)
// Tick our app, which lets us know if we want to quit
let exit = ghostty_app_tick(app)
if (!exit) { return }
// On iOS, applications do not terminate programmatically like they do
// on macOS. On iOS, applications are only terminated when a user physically
// closes the application (i.e. going to the home screen). If we request
// exit on iOS we ignore it.
#if os(iOS)
logger.info("quit request received, ignoring on iOS")
#endif
#if os(macOS)
// We want to quit, start that process
NSApplication.shared.terminate(nil)
#endif
} }
func openConfig() { func openConfig() {
@ -336,13 +320,13 @@ extension Ghostty {
let surfaceView = self.surfaceUserdata(from: userdata) let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return } guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard // Get our pasteboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) { guard let pasteboard = NSPasteboard.ghostty(location) else {
return completeClipboardRequest(surface, data: "", state: state) return completeClipboardRequest(surface, data: "", state: state)
} }
// Get our string // Get our string
let str = NSPasteboard.general.getOpinionatedStringContents() ?? "" let str = pasteboard.getOpinionatedStringContents() ?? ""
completeClipboardRequest(surface, data: str, state: state) completeClipboardRequest(surface, data: str, state: state)
} }
@ -380,14 +364,12 @@ extension Ghostty {
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) { static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
let surface = self.surfaceUserdata(from: userdata) let surface = self.surfaceUserdata(from: userdata)
// We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) { return }
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
guard let valueStr = String(cString: string!, encoding: .utf8) else { return } guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
if !confirm { if !confirm {
let pb = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil)
pb.declareTypes([.string], owner: nil) pasteboard.setString(valueStr, forType: .string)
pb.setString(valueStr, forType: .string)
return return
} }
@ -396,7 +378,7 @@ extension Ghostty {
object: surface, object: surface,
userInfo: [ userInfo: [
Notification.ConfirmClipboardStrKey: valueStr, Notification.ConfirmClipboardStrKey: valueStr,
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write, Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
] ]
) )
} }
@ -454,6 +436,9 @@ extension Ghostty {
// Action dispatch // Action dispatch
switch (action.tag) { switch (action.tag) {
case GHOSTTY_ACTION_QUIT:
quit(app)
case GHOSTTY_ACTION_NEW_WINDOW: case GHOSTTY_ACTION_NEW_WINDOW:
newWindow(app, target: target) newWindow(app, target: target)
@ -463,6 +448,9 @@ extension Ghostty {
case GHOSTTY_ACTION_NEW_SPLIT: case GHOSTTY_ACTION_NEW_SPLIT:
newSplit(app, target: target, direction: action.action.new_split) newSplit(app, target: target, direction: action.action.new_split)
case GHOSTTY_ACTION_CLOSE_TAB:
closeTab(app, target: target)
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen) toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen)
@ -559,6 +547,21 @@ extension Ghostty {
} }
} }
private static func quit(_ app: ghostty_app_t) {
// On iOS, applications do not terminate programmatically like they do
// on macOS. On iOS, applications are only terminated when a user physically
// closes the application (i.e. going to the home screen). If we request
// exit on iOS we ignore it.
#if os(iOS)
logger.info("quit request received, ignoring on iOS")
#endif
#if os(macOS)
// We want to quit, start that process
NSApplication.shared.terminate(nil)
#endif
}
private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) { switch (target.tag) {
case GHOSTTY_TARGET_APP: case GHOSTTY_TARGET_APP:
@ -651,6 +654,27 @@ extension Ghostty {
} }
} }
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("close tab does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyCloseTab,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func toggleFullscreen( private static func toggleFullscreen(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -132,15 +132,6 @@ extension Ghostty {
return v return v
} }
var windowColorspace: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
let key = "window-colorspace"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" }
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowSaveState: String { var windowSaveState: String {
guard let config = self.config else { return "" } guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
@ -174,11 +165,14 @@ extension Ghostty {
} }
var windowDecorations: Bool { var windowDecorations: Bool {
guard let config = self.config else { return true } let defaultValue = true
var v = false; guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil
let key = "window-decoration" let key = "window-decoration"
_ = ghostty_config_get(config, &v, key, UInt(key.count)) guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
return v; guard let ptr = v else { return defaultValue }
let str = String(cString: ptr)
return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue
} }
var windowTheme: String? { var windowTheme: String? {
@ -345,7 +339,7 @@ extension Ghostty {
var backgroundBlurRadius: Int { var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 } guard let config = self.config else { return 1 }
var v: Int = 0 var v: Int = 0
let key = "background-blur-radius" let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.count)) _ = ghostty_config_get(config, &v, key, UInt(key.count))
return v; return v;
} }
@ -375,13 +369,24 @@ extension Ghostty {
) )
} }
// This isn't actually a configurable value currently but it could be done day.
// We put it here because it is a color that changes depending on the configuration.
var splitDividerColor: Color { var splitDividerColor: Color {
let backgroundColor = OSColor(backgroundColor) let backgroundColor = OSColor(backgroundColor)
let isLightBackground = backgroundColor.isLightColor let isLightBackground = backgroundColor.isLightColor
let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4)
return Color(newColor)
guard let config = self.config else { return Color(newColor) }
var color: ghostty_config_color_s = .init();
let key = "split-divider-color"
if (!ghostty_config_get(config, &color, key, UInt(key.count))) {
return Color(newColor)
}
return .init(
red: Double(color.r) / 255,
green: Double(color.g) / 255,
blue: Double(color.b) / 255
)
} }
#if canImport(AppKit) #if canImport(AppKit)
@ -420,6 +425,16 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count)) _ = ghostty_config_get(config, &v, key, UInt(key.count))
return v return v
} }
var quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior {
guard let config = self.config else { return .move }
var v: UnsafePointer<Int8>? = nil
let key = "quick-terminal-space-behavior"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move }
guard let ptr = v else { return .move }
let str = String(cString: ptr)
return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move
}
#endif #endif
var resizeOverlay: ResizeOverlay { var resizeOverlay: ResizeOverlay {
@ -542,4 +557,18 @@ extension Ghostty.Config {
} }
} }
} }
enum WindowDecoration: String {
case none
case client
case server
case auto
func enabled() -> Bool {
switch self {
case .client, .server, .auto: return true
case .none: return false
}
}
}
} }

View File

@ -0,0 +1,15 @@
import Cocoa
import GhosttyKit
extension Ghostty {
/// A comparable event.
struct ComparableKeyEvent: Equatable {
let keyCode: UInt16
let flags: NSEvent.ModifierFlags
init(event: NSEvent) {
self.keyCode = event.keyCode
self.flags = event.modifierFlags
}
}
}

View File

@ -205,6 +205,7 @@ extension Ghostty {
alert.beginSheetModal(for: window, completionHandler: { response in alert.beginSheetModal(for: window, completionHandler: { response in
switch (response) { switch (response) {
case .alertFirstButtonReturn: case .alertFirstButtonReturn:
alert.window.orderOut(nil)
node = nil node = nil
default: default:

View File

@ -0,0 +1,15 @@
import Cocoa
import GhosttyKit
extension NSEvent {
/// Create a Ghostty key event for a given keyboard action.
func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s {
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(modifierFlags)
key_ev.keycode = UInt32(keyCode)
key_ev.text = nil
key_ev.composing = false
return key_ev
}
}

View File

@ -159,7 +159,7 @@ extension Ghostty {
case osc_52_read case osc_52_read
/// An application is attempting to write to the clipboard using OSC 52 /// An application is attempting to write to the clipboard using OSC 52
case osc_52_write case osc_52_write(OSPasteboard?)
/// The text to show in the clipboard confirmation prompt for a given request type /// The text to show in the clipboard confirmation prompt for a given request type
func text() -> String { func text() -> String {
@ -188,7 +188,7 @@ extension Ghostty {
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write return .osc_52_write(nil)
default: default:
return nil return nil
} }
@ -236,6 +236,9 @@ extension Notification.Name {
/// Goto tab. Has tab index in the userinfo. /// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue
/// Close tab
static let ghosttyCloseTab = Notification.Name("com.mitchellh.ghostty.closeTab")
} }
// NOTE: I am moving all of these to Notification.Name extensions over time. This // NOTE: I am moving all of these to Notification.Name extensions over time. This

View File

@ -92,22 +92,6 @@ extension Ghostty {
windowFocus = false windowFocus = false
} }
} }
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
providers.forEach { provider in
_ = provider.loadObject(ofClass: URL.self) { url, _ in
guard let url = url else { return }
let path = Shell.escape(url.path)
DispatchQueue.main.async {
surfaceView.insertText(
path,
replacementRange: NSMakeRange(0, 0)
)
}
}
}
return true
}
#endif #endif
// If our geo size changed then we show the resize overlay as configured. // If our geo size changed then we show the resize overlay as configured.

View File

@ -1,3 +1,4 @@
import AppKit
import SwiftUI import SwiftUI
import CoreText import CoreText
import UserNotifications import UserNotifications
@ -12,7 +13,14 @@ extension Ghostty {
// The current title of the surface as defined by the pty. This can be // 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 // changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there. // to the app level and it is set from there.
@Published private(set) var title: String = "👻" @Published private(set) var title: String = "" {
didSet {
if !title.isEmpty {
titleFallbackTimer?.invalidate()
titleFallbackTimer = nil
}
}
}
// The current pwd of the surface as defined by the pty. This can be // The current pwd of the surface as defined by the pty. This can be
// changed with escape codes. // changed with escape codes.
@ -113,6 +121,12 @@ extension Ghostty {
// A small delay that is introduced before a title change to avoid flickers // A small delay that is introduced before a title change to avoid flickers
private var titleChangeTimer: Timer? private var titleChangeTimer: Timer?
// A timer to fallback to ghost emoji if no title is set within the grace period
private var titleFallbackTimer: Timer?
/// Event monitor (see individual events for why)
private var eventMonitor: Any? = nil
// We need to support being a first responder so that we can get input events // We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true } override var acceptsFirstResponder: Bool { return true }
@ -136,6 +150,13 @@ extension Ghostty {
// can do SOMETHING. // can do SOMETHING.
super.init(frame: NSMakeRect(0, 0, 800, 600)) super.init(frame: NSMakeRect(0, 0, 800, 600))
// Set a timer to show the ghost emoji after 500ms if no title is set
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
if let self = self, self.title.isEmpty {
self.title = "👻"
}
}
// Before we initialize the surface we want to register our notifications // Before we initialize the surface we want to register our notifications
// so there is no window where we can't receive them. // so there is no window where we can't receive them.
let center = NotificationCenter.default let center = NotificationCenter.default
@ -170,6 +191,15 @@ extension Ghostty {
name: NSWindow.didChangeScreenNotification, name: NSWindow.didChangeScreenNotification,
object: nil) object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [
// We need keyUp because command+key events don't trigger keyUp.
.keyUp
]
) { [weak self] event in self?.localEventHandler(event) }
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
@ -201,6 +231,9 @@ extension Ghostty {
ghostty_surface_set_color_scheme(surface, scheme) ghostty_surface_set_color_scheme(surface, scheme)
} }
// The UTTypes that can be dragged onto this view.
registerForDraggedTypes(Array(Self.dropTypes))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -212,6 +245,11 @@ extension Ghostty {
let center = NotificationCenter.default let center = NotificationCenter.default
center.removeObserver(self) center.removeObserver(self)
// Remove our event monitor
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
// Whenever the surface is removed, we need to note that our restorable // Whenever the surface is removed, we need to note that our restorable
// state is invalid to prevent the surface from being restored. // state is invalid to prevent the surface from being restored.
invalidateRestorableState() invalidateRestorableState()
@ -356,6 +394,30 @@ extension Ghostty {
} }
} }
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
return switch event.type {
case .keyUp:
localEventKeyUp(event)
default:
event
}
}
private func localEventKeyUp(_ event: NSEvent) -> NSEvent? {
// We only care about events with "command" because all others will
// trigger the normal responder chain.
if (!event.modifierFlags.contains(.command)) { return event }
// Command keyUp events are never sent to the normal responder chain
// so we send them here.
guard focused else { return event }
self.keyUp(with: event)
return nil
}
// MARK: - Notifications // MARK: - Notifications
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
@ -764,16 +826,51 @@ extension Ghostty {
// know if these events cleared it. // know if these events cleared it.
let markedTextBefore = markedText.length > 0 let markedTextBefore = markedText.length > 0
// We need to know the keyboard layout before below because some keyboard
// input events will change our keyboard layout and we don't want those
// going to the terminal.
let keyboardIdBefore: String? = if (!markedTextBefore) {
KeyboardLayout.id
} else {
nil
}
self.interpretKeyEvents([translationEvent]) self.interpretKeyEvents([translationEvent])
// If our keyboard changed from this we just assume an input method
// grabbed it and do nothing.
if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) {
return
}
// If we have text, then we've composed a character, send that down. We do this // If we have text, then we've composed a character, send that down. We do this
// first because if we completed a preedit, the text will be available here // first because if we completed a preedit, the text will be available here
// AND we'll have a preedit. // AND we'll have a preedit.
var handled: Bool = false var handled: Bool = false
if let list = keyTextAccumulator, list.count > 0 { if let list = keyTextAccumulator, list.count > 0 {
handled = true handled = true
for text in list {
keyAction(action, event: event, text: text) // This is a hack. libghostty on macOS treats ctrl input as not having
// text because some keyboard layouts generate bogus characters for
// ctrl+key. libghostty can't tell this is from an IM keyboard giving
// us direct values. So, we just remove control.
var modifierFlags = event.modifierFlags
modifierFlags.remove(.control)
if let keyTextEvent = NSEvent.keyEvent(
with: .keyDown,
location: event.locationInWindow,
modifierFlags: modifierFlags,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: event.characters ?? "",
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
isARepeat: event.isARepeat,
keyCode: event.keyCode
) {
for text in list {
_ = keyAction(action, event: keyTextEvent, text: text)
}
} }
} }
@ -783,38 +880,49 @@ extension Ghostty {
// the preedit. // the preedit.
if (markedText.length > 0 || markedTextBefore) { if (markedText.length > 0 || markedTextBefore) {
handled = true handled = true
keyAction(action, event: event, preedit: markedText.string) _ = keyAction(action, event: event, preedit: markedText.string)
} }
if (!handled) { if (!handled) {
// No text or anything, we want to handle this manually. // No text or anything, we want to handle this manually.
keyAction(action, event: event) _ = keyAction(action, event: event)
} }
} }
override func keyUp(with event: NSEvent) { override func keyUp(with event: NSEvent) {
keyAction(GHOSTTY_ACTION_RELEASE, event: event) _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event)
} }
/// Special case handling for some control keys /// Special case handling for some control keys
override func performKeyEquivalent(with event: NSEvent) -> Bool { override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Only process key down events switch (event.type) {
if (event.type != .keyDown) { 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 return false
} }
// Only process events if we're focused. Some key events like C-/ macOS // 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 // 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. // the first responder (I don't know why). This prevents us from handling it.
// Besides C-/, its important we don't process key equivalents if unfocused
// because there are other event listeners for that (i.e. AppDelegate's
// local event handler).
if (!focused) { if (!focused) {
return false return false
} }
// Only process keys when Control is active. All known issues we're // If this event as-is would result in a key binding then we send it.
// resolving happen only in this scenario. This probably isn't fully robust if let surface,
// but we can broaden the scope as we find more cases. ghostty_surface_key_is_binding(
if (!event.modifierFlags.contains(.control)) { surface,
return false event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
self.keyDown(with: event)
return true
} }
let equivalent: String let equivalent: String
@ -832,14 +940,25 @@ extension Ghostty {
case "\r": case "\r":
// Pass C-<return> through verbatim // Pass C-<return> through verbatim
// (prevent the default context menu equivalent) // (prevent the default context menu equivalent)
if (!event.modifierFlags.contains(.control)) {
return false
}
equivalent = "\r" equivalent = "\r"
case ".":
if (!event.modifierFlags.contains(.command)) {
return false
}
equivalent = "."
default: default:
// Ignore other events // Ignore other events
return false return false
} }
let newEvent = NSEvent.keyEvent( let finalEvent = NSEvent.keyEvent(
with: .keyDown, with: .keyDown,
location: event.locationInWindow, location: event.locationInWindow,
modifierFlags: event.modifierFlags, modifierFlags: event.modifierFlags,
@ -852,7 +971,7 @@ extension Ghostty {
keyCode: event.keyCode keyCode: event.keyCode
) )
self.keyDown(with: newEvent!) self.keyDown(with: finalEvent!)
return true return true
} }
@ -867,6 +986,9 @@ extension Ghostty {
default: return default: return
} }
// If we're in the middle of a preedit, don't do anything with mods.
if hasMarkedText() { return }
// The keyAction function will do this AGAIN below which sucks to repeat // The keyAction function will do this AGAIN below which sucks to repeat
// but this is super cheap and flagsChanged isn't that common. // but this is super cheap and flagsChanged isn't that common.
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mods = Ghostty.ghosttyMods(event.modifierFlags)
@ -897,45 +1019,38 @@ extension Ghostty {
} }
} }
keyAction(action, event: event) _ = keyAction(action, event: event)
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool {
guard let surface = self.surface else { return } guard let surface = self.surface else { return false }
return ghostty_surface_key(surface, event.ghosttyKeyEvent(action))
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
ghostty_surface_key(surface, key_ev)
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { private func keyAction(
guard let surface = self.surface else { return } _ action: ghostty_input_action_e,
event: NSEvent, preedit: String
) -> Bool {
guard let surface = self.surface else { return false }
preedit.withCString { ptr in return preedit.withCString { ptr in
var key_ev = ghostty_input_key_s() var key_ev = event.ghosttyKeyEvent(action)
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr key_ev.text = ptr
key_ev.composing = true key_ev.composing = true
ghostty_surface_key(surface, key_ev) return ghostty_surface_key(surface, key_ev)
} }
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { private func keyAction(
guard let surface = self.surface else { return } _ action: ghostty_input_action_e,
event: NSEvent, text: String
) -> Bool {
guard let surface = self.surface else { return false }
text.withCString { ptr in return text.withCString { ptr in
var key_ev = ghostty_input_key_s() var key_ev = event.ghosttyKeyEvent(action)
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr key_ev.text = ptr
ghostty_surface_key(surface, key_ev) return ghostty_surface_key(surface, key_ev)
} }
} }
@ -1053,6 +1168,14 @@ extension Ghostty {
} }
} }
@IBAction func pasteSelection(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "paste_from_selection"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction override func selectAll(_ sender: Any?) { @IBAction override func selectAll(_ sender: Any?) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let action = "select_all" let action = "select_all"
@ -1374,3 +1497,78 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
return true return true
} }
} }
// MARK: NSMenuItemValidation
extension Ghostty.SurfaceView: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(pasteSelection):
let pb = NSPasteboard.ghosttySelection
guard let str = pb.getOpinionatedStringContents() else { return false }
return !str.isEmpty
default:
return true
}
}
}
// MARK: NSDraggingDestination
extension Ghostty.SurfaceView {
static let dropTypes: Set<NSPasteboard.PasteboardType> = [
.string,
.fileURL,
.URL
]
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard let types = sender.draggingPasteboard.types else { return [] }
// If the dragging object contains none of our types then we return none.
// This shouldn't happen because AppKit should guarantee that we only
// receive types we registered for but its good to check.
if Set(types).isDisjoint(with: Self.dropTypes) {
return []
}
// We use copy to get the proper icon
return .copy
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
let pb = sender.draggingPasteboard
let content: String?
if let url = pb.string(forType: .URL) {
// URLs first, they get escaped as-is.
content = Ghostty.Shell.escape(url)
} else if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
// File URLs next. They get escaped individually and then joined by a
// space if there are multiple.
content = urls
.map { Ghostty.Shell.escape($0.path) }
.joined(separator: " ")
} else if let str = pb.string(forType: .string) {
// Strings are not escaped because they may be copy/pasting a
// command they want to execute.
content = str
} else {
content = nil
}
if let content {
DispatchQueue.main.async {
self.insertText(
content,
replacementRange: NSMakeRange(0, 0)
)
}
return true
}
return false
}
}

View File

@ -10,6 +10,7 @@ import AppKit
typealias OSView = NSView typealias OSView = NSView
typealias OSColor = NSColor typealias OSColor = NSColor
typealias OSSize = NSSize typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView associatedtype OSViewType: NSView
@ -34,6 +35,7 @@ import UIKit
typealias OSView = UIView typealias OSView = UIView
typealias OSColor = UIColor typealias OSColor = UIColor
typealias OSSize = CGSize typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
protocol OSViewRepresentable: UIViewRepresentable { protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView associatedtype OSViewType: UIView

View File

@ -0,0 +1,33 @@
import Cocoa
// Private API to get Dock location
@_silgen_name("CoreDockGetOrientationAndPinning")
func CoreDockGetOrientationAndPinning(
_ outOrientation: UnsafeMutablePointer<Int32>,
_ outPinning: UnsafeMutablePointer<Int32>)
// Private API to get the current Dock auto-hide state
@_silgen_name("CoreDockGetAutoHideEnabled")
func CoreDockGetAutoHideEnabled() -> Bool
enum DockOrientation: Int {
case top = 1
case bottom = 2
case left = 3
case right = 4
}
class Dock {
/// Returns the orientation of the dock or nil if it can't be determined.
static var orientation: DockOrientation? {
var orientation: Int32 = 0
var pinning: Int32 = 0
CoreDockGetOrientationAndPinning(&orientation, &pinning)
return .init(rawValue: Int(orientation)) ?? nil
}
/// Returns true if the dock has auto-hide enabled.
static var autoHideEnabled: Bool {
return CoreDockGetAutoHideEnabled()
}
}

View File

@ -307,21 +307,21 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// MARK: Dock // MARK: Dock
private func hideDock() { private func hideDock() {
NSApp.presentationOptions.insert(.autoHideDock) NSApp.acquirePresentationOption(.autoHideDock)
} }
private func unhideDock() { private func unhideDock() {
NSApp.presentationOptions.remove(.autoHideDock) NSApp.releasePresentationOption(.autoHideDock)
} }
// MARK: Menu // MARK: Menu
func hideMenu() { func hideMenu() {
NSApp.presentationOptions.insert(.autoHideMenuBar) NSApp.acquirePresentationOption(.autoHideMenuBar)
} }
func unhideMenu() { func unhideMenu() {
NSApp.presentationOptions.remove(.autoHideMenuBar) NSApp.releasePresentationOption(.autoHideMenuBar)
} }
/// The state that must be saved for non-native fullscreen to exit fullscreen. /// The state that must be saved for non-native fullscreen to exit fullscreen.

View File

@ -0,0 +1,14 @@
import Carbon
class KeyboardLayout {
/// Return a string ID of the current keyboard input source.
static var id: String? {
if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) {
let sourceId = unsafeBitCast(sourceIdPointer, to: CFString.self)
return sourceId as String
}
return nil
}
}

View File

@ -0,0 +1,31 @@
import Cocoa
extension NSApplication {
private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:]
/// Add a presentation option to the application and main a reference count so that and equal
/// number of pops is required to disable it. This is useful so that multiple classes can affect global
/// app state without overriding others.
func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
Self.presentationOptionCounts[option, default: 0] += 1
presentationOptions.insert(option)
}
/// See acquirePresentationOption
func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) {
guard let value = Self.presentationOptionCounts[option] else { return }
guard value > 0 else { return }
if (value == 1) {
presentationOptions.remove(option)
Self.presentationOptionCounts.removeValue(forKey: option)
} else {
Self.presentationOptionCounts[option] = value - 1
}
}
}
extension NSApplication.PresentationOptions.Element: @retroactive Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(rawValue)
}
}

View File

@ -1,17 +1,39 @@
import AppKit import AppKit
import GhosttyKit
extension NSPasteboard { extension NSPasteboard {
/// The pasteboard to used for Ghostty selection.
static var ghosttySelection: NSPasteboard = {
NSPasteboard(name: .init("com.mitchellh.ghostty.selection"))
}()
/// Gets the contents of the pasteboard as a string following a specific set of semantics. /// Gets the contents of the pasteboard as a string following a specific set of semantics.
/// Does these things in order: /// Does these things in order:
/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one. /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard. /// - Tries to get any string from the pasteboard.
/// If all of the above fail, returns None. /// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? { func getOpinionatedStringContents() -> String? {
if let file = self.string(forType: .fileURL) { if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
if let path = NSURL(string: file)?.path { urls.count > 0 {
return path return urls
} .map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString }
.joined(separator: " ")
} }
return self.string(forType: .string) return self.string(forType: .string)
} }
/// The pasteboard for the Ghostty enum type.
static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? {
switch (clipboard) {
case GHOSTTY_CLIPBOARD_STANDARD:
return Self.general
case GHOSTTY_CLIPBOARD_SELECTION:
return Self.ghosttySelection
default:
return nil
}
}
} }

View File

@ -0,0 +1,9 @@
/// A wrapper that holds a weak reference to an object. This lets us create native containers
/// of weak references.
class Weak<T: AnyObject> {
weak var value: T?
init(_ value: T) {
self.value = value
}
}

View File

@ -51,6 +51,9 @@
pandoc, pandoc,
hyperfine, hyperfine,
typos, typos,
wayland,
wayland-scanner,
wayland-protocols,
}: let }: let
# See package.nix. Keep in sync. # See package.nix. Keep in sync.
rpathLibs = rpathLibs =
@ -80,6 +83,7 @@
libadwaita libadwaita
gtk4 gtk4
glib glib
wayland
]; ];
in in
mkShell { mkShell {
@ -153,6 +157,9 @@ in
libadwaita libadwaita
gtk4 gtk4
glib glib
wayland
wayland-scanner
wayland-protocols
]; ];
# This should be set onto the rpath of the ghostty binary if you want # This should be set onto the rpath of the ghostty binary if you want

View File

@ -10,10 +10,6 @@
oniguruma, oniguruma,
zlib, zlib,
libGL, libGL,
libX11,
libXcursor,
libXi,
libXrandr,
glib, glib,
gtk4, gtk4,
libadwaita, libadwaita,
@ -26,7 +22,15 @@
pandoc, pandoc,
revision ? "dirty", revision ? "dirty",
optimize ? "Debug", optimize ? "Debug",
x11 ? true, enableX11 ? true,
libX11,
libXcursor,
libXi,
libXrandr,
enableWayland ? true,
wayland,
wayland-protocols,
wayland-scanner,
}: let }: let
# The Zig hook has no way to select the release type without actual # The Zig hook has no way to select the release type without actual
# overriding of the default flags. # overriding of the default flags.
@ -49,7 +53,6 @@
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [ lib.fileset.unions [
../dist/linux ../dist/linux
../conformance
../images ../images
../include ../include
../pkg ../pkg
@ -114,14 +117,19 @@ in
version = "1.0.2"; version = "1.0.2";
inherit src; inherit src;
nativeBuildInputs = [ nativeBuildInputs =
git [
ncurses git
pandoc ncurses
pkg-config pandoc
zig_hook pkg-config
wrapGAppsHook4 zig_hook
]; wrapGAppsHook4
]
++ lib.optionals enableWayland [
wayland-scanner
wayland-protocols
];
buildInputs = buildInputs =
[ [
@ -142,16 +150,19 @@ in
glib glib
gsettings-desktop-schemas gsettings-desktop-schemas
] ]
++ lib.optionals x11 [ ++ lib.optionals enableX11 [
libX11 libX11
libXcursor libXcursor
libXi libXi
libXrandr libXrandr
]
++ lib.optionals enableWayland [
wayland
]; ];
dontConfigure = true; dontConfigure = true;
zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString x11}"; zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString enableX11} -Dgtk-wayland=${lib.boolToString enableWayland}";
preBuild = '' preBuild = ''
rm -rf $ZIG_GLOBAL_CACHE_DIR rm -rf $ZIG_GLOBAL_CACHE_DIR

View File

@ -0,0 +1,18 @@
{...}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
lightdm = {
enable = true;
};
};
desktopManager = {
cinnamon = {
enable = true;
};
};
};
}

136
nix/vm/common-gnome.nix Normal file
View File

@ -0,0 +1,136 @@
{
config,
lib,
pkgs,
...
}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
gdm = {
enable = true;
autoSuspend = false;
};
};
desktopManager = {
gnome = {
enable = true;
};
};
};
environment.systemPackages = [
pkgs.gnomeExtensions.no-overview
];
environment.gnome.excludePackages = with pkgs; [
atomix
baobab
cheese
epiphany
evince
file-roller
geary
gnome-backgrounds
gnome-calculator
gnome-calendar
gnome-clocks
gnome-connections
gnome-contacts
gnome-disk-utility
gnome-extension-manager
gnome-logs
gnome-maps
gnome-music
gnome-photos
gnome-software
gnome-system-monitor
gnome-text-editor
gnome-themes-extra
gnome-tour
gnome-user-docs
gnome-weather
hitori
iagno
loupe
nautilus
orca
seahorse
simple-scan
snapshot
sushi
tali
totem
yelp
];
programs.dconf = {
enable = true;
profiles.user.databases = [
{
settings = with lib.gvariant; {
"org/gnome/desktop/background" = {
picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-options = "centered";
primary-color = "#000000000000";
secondary-color = "#000000000000";
};
"org/gnome/desktop/interface" = {
color-scheme = "prefer-dark";
};
"org/gnome/desktop/notifications" = {
show-in-lock-screen = false;
};
"org/gnome/desktop/screensaver" = {
lock-enabled = false;
picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png";
picture-options = "centered";
primary-color = "#000000000000";
secondary-color = "#000000000000";
};
"org/gnome/desktop/session" = {
idle-delay = mkUint32 0;
};
"org/gnome/shell" = {
disable-user-extensions = false;
enabled-extensions = builtins.map (x: x.extensionUuid) (
lib.filter (p: p ? extensionUuid) config.environment.systemPackages
);
};
};
}
];
};
programs.geary.enable = false;
services.gnome = {
gnome-browser-connector.enable = false;
gnome-initial-setup.enable = false;
gnome-online-accounts.enable = false;
gnome-remote-desktop.enable = false;
rygel.enable = false;
};
system.activationScripts = {
face = {
text = ''
mkdir -p /var/lib/AccountsService/{icons,users}
cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty
echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty
chown root:root /var/lib/AccountsService/users/ghostty
chmod 0600 /var/lib/AccountsService/users/ghostty
chown root:root /var/lib/AccountsService/icons/ghostty
chmod 0444 /var/lib/AccountsService/icons/ghostty
'';
};
};
}

21
nix/vm/common-plasma6.nix Normal file
View File

@ -0,0 +1,21 @@
{...}: {
imports = [
./common.nix
];
services = {
displayManager = {
sddm = {
enable = true;
wayland = {
enable = true;
};
};
};
desktopManager = {
plasma6 = {
enable = true;
};
};
};
}

18
nix/vm/common-xfce.nix Normal file
View File

@ -0,0 +1,18 @@
{...}: {
imports = [
./common.nix
];
services.xserver = {
displayManager = {
lightdm = {
enable = true;
};
};
desktopManager = {
xfce = {
enable = true;
};
};
};
}

83
nix/vm/common.nix Normal file
View File

@ -0,0 +1,83 @@
{pkgs, ...}: {
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
documentation.nixos.enable = false;
networking.hostName = "ghostty";
networking.domain = "mitchellh.com";
virtualisation.vmVariant = {
virtualisation.memorySize = 2048;
};
nix = {
settings = {
trusted-users = [
"root"
"ghostty"
];
};
extraOptions = ''
experimental-features = nix-command flakes
'';
};
users.mutableUsers = false;
users.groups.ghostty = {};
users.users.ghostty = {
description = "Ghostty";
group = "ghostty";
extraGroups = ["wheel"];
isNormalUser = true;
initialPassword = "ghostty";
};
environment.etc = {
"xdg/autostart/com.mitchellh.ghostty.desktop" = {
source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop";
};
};
environment.systemPackages = [
pkgs.kitty
pkgs.fish
pkgs.ghostty
pkgs.helix
pkgs.neovim
pkgs.xterm
pkgs.zsh
];
security.polkit = {
enable = true;
};
services.dbus = {
enable = true;
};
services.displayManager = {
autoLogin = {
user = "ghostty";
};
};
services.libinput = {
enable = true;
};
services.qemuGuest = {
enable = true;
};
services.spice-vdagentd = {
enable = true;
};
services.xserver = {
enable = true;
};
}

View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-cinnamon.nix;
}

12
nix/vm/create-gnome.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-gnome.nix;
}

12
nix/vm/create-plasma6.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-plasma6.nix;
}

12
nix/vm/create-xfce.nix Normal file
View File

@ -0,0 +1,12 @@
{
system,
nixpkgs,
overlay,
module,
uid ? 1000,
gid ? 1000,
}:
import ./create.nix {
inherit system nixpkgs overlay module uid gid;
common = ./common-xfce.nix;
}

42
nix/vm/create.nix Normal file
View File

@ -0,0 +1,42 @@
{
system,
nixpkgs,
overlay,
module,
common ? ./common.nix,
uid ? 1000,
gid ? 1000,
}: let
pkgs = import nixpkgs {
inherit system;
overlays = [
overlay
];
};
in
nixpkgs.lib.nixosSystem {
system = builtins.replaceStrings ["darwin"] ["linux"] system;
modules = [
{
virtualisation.vmVariant = {
virtualisation.host.pkgs = pkgs;
};
nixpkgs.overlays = [
overlay
];
users.groups.ghostty = {
gid = gid;
};
users.users.ghostty = {
uid = uid;
};
system.stateVersion = nixpkgs.lib.trivial.release;
}
common
module
];
}

View File

@ -0,0 +1,7 @@
{...}: {
imports = [
./common-cinnamon.nix
];
services.displayManager.defaultSession = "cinnamon-wayland";
}

9
nix/vm/wayland-gnome.nix Normal file
View File

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

View File

@ -0,0 +1,6 @@
{...}: {
imports = [
./common-plasma6.nix
];
services.displayManager.defaultSession = "plasma";
}

7
nix/vm/x11-cinnamon.nix Normal file
View File

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

9
nix/vm/x11-gnome.nix Normal file
View File

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

6
nix/vm/x11-plasma6.nix Normal file
View File

@ -0,0 +1,6 @@
{...}: {
imports = [
./common-plasma6.nix
];
services.displayManager.defaultSession = "plasmax11";
}

7
nix/vm/x11-xfce.nix Normal file
View File

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

View File

@ -1,3 +1,3 @@
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for # This file is auto-generated! check build-support/check-zig-cache-hash.sh for
# more details. # more details.
"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY=" "sha256-Bjy31evaKgpRX1mGwAFkai44eiiorTV1gW3VdP9Ins8="

View File

@ -56,7 +56,7 @@ pub fn build(b: *std.Build) !void {
} }
} }
pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target; const target = options.target;
const optimize = options.optimize; const optimize = options.optimize;
@ -186,7 +186,7 @@ pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*st
_ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help _ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help
if (freetype_enabled) { if (freetype_enabled) {
if (b.systemIntegrationOption("freetype", .{})) { if (b.systemIntegrationOption("freetype", .{})) {
lib.linkSystemLibrary2("freetype", dynamic_link_opts); lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else { } else {
const freetype_dep = b.dependency( const freetype_dep = b.dependency(
"freetype", "freetype",

View File

@ -5,7 +5,61 @@ pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false; const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false;
const module = b.addModule("freetype", .{ .root_source_file = b.path("main.zig") }); const module = b.addModule("freetype", .{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
// 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,
};
var test_exe: ?*std.Build.Step.Compile = null;
if (target.query.isNative()) {
test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
const tests_run = b.addRunArtifact(test_exe.?);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
module.addIncludePath(b.path(""));
if (b.systemIntegrationOption("freetype", .{})) {
module.linkSystemLibrary("freetype2", dynamic_link_opts);
if (test_exe) |exe| {
exe.linkSystemLibrary2("freetype2", dynamic_link_opts);
}
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
.libpng_enabled = libpng_enabled,
.dynamic_link_opts = dynamic_link_opts,
});
if (test_exe) |exe| {
exe.linkLibrary(lib);
}
}
}
fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target;
const optimize = options.optimize;
const libpng_enabled = options.libpng_enabled;
const upstream = b.dependency("freetype", .{}); const upstream = b.dependency("freetype", .{});
const lib = b.addStaticLibrary(.{ const lib = b.addStaticLibrary(.{
@ -21,16 +75,6 @@ pub fn build(b: *std.Build) !void {
} }
module.addIncludePath(upstream.path("include")); module.addIncludePath(upstream.path("include"));
module.addIncludePath(b.path(""));
// 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,
};
var flags = std.ArrayList([]const u8).init(b.allocator); var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit(); defer flags.deinit();
try flags.appendSlice(&.{ try flags.appendSlice(&.{
@ -44,6 +88,8 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize=undefined", "-fno-sanitize=undefined",
}); });
const dynamic_link_opts = options.dynamic_link_opts;
// Zlib // Zlib
if (b.systemIntegrationOption("zlib", .{})) { if (b.systemIntegrationOption("zlib", .{})) {
lib.linkSystemLibrary2("zlib", dynamic_link_opts); lib.linkSystemLibrary2("zlib", dynamic_link_opts);
@ -113,18 +159,7 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib); b.installArtifact(lib);
if (target.query.isNative()) { return lib;
const test_exe = b.addTest(.{
.name = "test",
.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);
}
} }
const srcs: []const []const u8 = &.{ const srcs: []const []const u8 = &.{

View File

@ -43,7 +43,11 @@ pub fn build(b: *std.Build) !void {
{ {
var it = module.import_table.iterator(); var it = module.import_table.iterator();
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
test_exe.linkLibrary(freetype.artifact("freetype")); if (b.systemIntegrationOption("freetype", .{})) {
test_exe.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
test_exe.linkLibrary(freetype.artifact("freetype"));
}
const tests_run = b.addRunArtifact(test_exe); const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step); test_step.dependOn(&tests_run.step);
@ -67,7 +71,7 @@ pub fn build(b: *std.Build) !void {
} }
} }
pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target; const target = options.target;
const optimize = options.optimize; const optimize = options.optimize;

View File

@ -18,9 +18,72 @@ pub const ColorSpace = opaque {
) orelse Allocator.Error.OutOfMemory; ) orelse Allocator.Error.OutOfMemory;
} }
pub fn createNamed(name: Name) Allocator.Error!*ColorSpace {
return @as(
?*ColorSpace,
@ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))),
) orelse Allocator.Error.OutOfMemory;
}
pub fn release(self: *ColorSpace) void { pub fn release(self: *ColorSpace) void {
c.CGColorSpaceRelease(@ptrCast(self)); c.CGColorSpaceRelease(@ptrCast(self));
} }
pub const Name = enum {
/// This color space uses the DCI P3 primaries, a D65 white point, and
/// the sRGB transfer function.
displayP3,
/// The Display P3 color space with a linear transfer function and
/// extended-range values.
extendedLinearDisplayP3,
/// The sRGB colorimetry and non-linear transfer function are specified
/// in IEC 61966-2-1.
sRGB,
/// This color space has the same colorimetry as `sRGB`, but uses a
/// linear transfer function.
linearSRGB,
/// This color space has the same colorimetry as `sRGB`, but you can
/// encode component values below `0.0` and above `1.0`. Negative values
/// are encoded as the signed reflection of the original encoding
/// function, as shown in the formula below:
/// ```
/// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x))
/// ```
extendedSRGB,
/// This color space has the same colorimetry as `sRGB`; in addition,
/// you may encode component values below `0.0` and above `1.0`.
extendedLinearSRGB,
/// ...
genericGrayGamma2_2,
/// ...
linearGray,
/// This color space has the same colorimetry as `genericGrayGamma2_2`,
/// but you can encode component values below `0.0` and above `1.0`.
/// Negative values are encoded as the signed reflection of the
/// original encoding function, as shown in the formula below:
/// ```
/// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x))
/// ```
extendedGray,
/// This color space has the same colorimetry as `linearGray`; in
/// addition, you may encode component values below `0.0` and above `1.0`.
extendedLinearGray,
fn cfstring(self: Name) c.CFStringRef {
return switch (self) {
.displayP3 => c.kCGColorSpaceDisplayP3,
.extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3,
.sRGB => c.kCGColorSpaceSRGB,
.extendedSRGB => c.kCGColorSpaceExtendedSRGB,
.linearSRGB => c.kCGColorSpaceLinearSRGB,
.extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB,
.genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2,
.extendedGray => c.kCGColorSpaceExtendedGray,
.linearGray => c.kCGColorSpaceLinearGray,
.extendedLinearGray => c.kCGColorSpaceExtendedLinearGray,
};
}
};
}; };
test { test {

View File

@ -53,7 +53,7 @@ pub fn build(b: *std.Build) !void {
} }
} }
pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target; const target = options.target;
const optimize = options.optimize; const optimize = options.optimize;

View File

@ -162,4 +162,26 @@ pub const Binding = struct {
data, data,
); );
} }
pub fn copySubImage2D(
b: Binding,
level: c.GLint,
xoffset: c.GLint,
yoffset: c.GLint,
x: c.GLint,
y: c.GLint,
width: c.GLsizei,
height: c.GLsizei,
) !void {
glad.context.CopyTexSubImage2D.?(
@intFromEnum(b.target),
level,
xoffset,
yoffset,
x,
y,
width,
height
);
}
}; };

View File

@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set( c.wuffs_base__pixel_config__set(
&image_config.pixcfg, &image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width, width,
height, height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status); try check(log, &status);
} }
var frame_config: c.wuffs_base__frame_config = undefined;
{
const status = c.wuffs_jpeg__decoder__decode_frame_config(
decoder,
&frame_config,
&source_buffer,
);
try check(log, &status);
}
{ {
const status = c.wuffs_jpeg__decoder__decode_frame( const status = c.wuffs_jpeg__decoder__decode_frame(
decoder, decoder,

View File

@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set( c.wuffs_base__pixel_config__set(
&image_config.pixcfg, &image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width, width,
height, height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
try check(log, &status); try check(log, &status);
} }
var frame_config: c.wuffs_base__frame_config = undefined;
{
const status = c.wuffs_png__decoder__decode_frame_config(
decoder,
&frame_config,
&source_buffer,
);
try check(log, &status);
}
{ {
const status = c.wuffs_png__decoder__decode_frame( const status = c.wuffs_png__decoder__decode_frame(
decoder, decoder,

View File

@ -54,9 +54,6 @@ focused_surface: ?*Surface = null,
/// this is a blocking queue so if it is full you will get errors (or block). /// this is a blocking queue so if it is full you will get errors (or block).
mailbox: Mailbox.Queue, mailbox: Mailbox.Queue,
/// Set to true once we're quitting. This never goes false again.
quit: bool,
/// The set of font GroupCache instances shared by surfaces with the /// The set of font GroupCache instances shared by surfaces with the
/// same font configuration. /// same font configuration.
font_grid_set: font.SharedGridSet, font_grid_set: font.SharedGridSet,
@ -98,7 +95,6 @@ pub fn create(
.alloc = alloc, .alloc = alloc,
.surfaces = .{}, .surfaces = .{},
.mailbox = .{}, .mailbox = .{},
.quit = false,
.font_grid_set = font_grid_set, .font_grid_set = font_grid_set,
.config_conditional_state = .{}, .config_conditional_state = .{},
}; };
@ -125,9 +121,7 @@ pub fn destroy(self: *App) void {
/// Tick ticks the app loop. This will drain our mailbox and process those /// Tick ticks the app loop. This will drain our mailbox and process those
/// events. This should be called by the application runtime on every loop /// events. This should be called by the application runtime on every loop
/// tick. /// tick.
/// pub fn tick(self: *App, rt_app: *apprt.App) !void {
/// This returns whether the app should quit or not.
pub fn tick(self: *App, rt_app: *apprt.App) !bool {
// If any surfaces are closing, destroy them // If any surfaces are closing, destroy them
var i: usize = 0; var i: usize = 0;
while (i < self.surfaces.items.len) { while (i < self.surfaces.items.len) {
@ -142,13 +136,6 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool {
// Drain our mailbox // Drain our mailbox
try self.drainMailbox(rt_app); try self.drainMailbox(rt_app);
// No matter what, we reset the quit flag after a tick. If the apprt
// doesn't want to quit, then we can't force it to.
defer self.quit = false;
// We quit if our quit flag is on
return self.quit;
} }
/// Update the configuration associated with the app. This can only be /// Update the configuration associated with the app. This can only be
@ -272,7 +259,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
// can try to quit as quickly as possible. // can try to quit as quickly as possible.
.quit => { .quit => {
log.info("quit message received, short circuiting mailbox drain", .{}); log.info("quit message received, short circuiting mailbox drain", .{});
self.setQuit(); try self.performAction(rt_app, .quit);
return; return;
}, },
} }
@ -314,12 +301,6 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
); );
} }
/// Start quitting
pub fn setQuit(self: *App) void {
if (self.quit) return;
self.quit = true;
}
/// Handle an app-level focus event. This should be called whenever /// Handle an app-level focus event. This should be called whenever
/// the focus state of the entire app containing Ghostty changes. /// the focus state of the entire app containing Ghostty changes.
/// This is separate from surface focus events. See the `focused` /// This is separate from surface focus events. See the `focused`
@ -332,6 +313,25 @@ pub fn focusEvent(self: *App, focused: bool) void {
self.focused = focused; self.focused = focused;
} }
/// Returns true if the given key event would trigger a keybinding
/// if it were to be processed. This is useful for determining if
/// a key event should be sent to the terminal or not.
pub fn keyEventIsBinding(
self: *App,
rt_app: *apprt.App,
event: input.KeyEvent,
) bool {
_ = self;
switch (event.action) {
.release => return false,
.press, .repeat => {},
}
// If we have a keybinding for this event then we return true.
return rt_app.config.keybind.set.getEvent(event) != null;
}
/// Handle a key event at the app-scope. If this key event is used, /// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing /// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false. /// the event. If the event is not used, this will return false.
@ -437,7 +437,7 @@ pub fn performAction(
switch (action) { switch (action) {
.unbind => unreachable, .unbind => unreachable,
.ignore => {}, .ignore => {},
.quit => self.setQuit(), .quit => try rt_app.performAction(.app, .quit, {}),
.new_window => try self.newWindow(rt_app, .{ .parent = null }), .new_window => try self.newWindow(rt_app, .{ .parent = null }),
.open_config => try rt_app.performAction(.app, .open_config, {}), .open_config => try rt_app.performAction(.app, .open_config, {}),
.reload_config => try rt_app.performAction(.app, .reload_config, .{}), .reload_config => try rt_app.performAction(.app, .reload_config, .{}),

View File

@ -569,12 +569,16 @@ pub fn init(
// Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
// but is otherwise somewhat arbitrary. // but is otherwise somewhat arbitrary.
const min_window_width_cells: u32 = 10;
const min_window_height_cells: u32 = 4;
try rt_app.performAction( try rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.size_limit, .size_limit,
.{ .{
.min_width = size.cell.width * 10, .min_width = size.cell.width * min_window_width_cells,
.min_height = size.cell.height * 4, .min_height = size.cell.height * min_window_height_cells,
// No max: // No max:
.max_width = 0, .max_width = 0,
.max_height = 0, .max_height = 0,
@ -617,8 +621,8 @@ pub fn init(
// start messing with the window. // start messing with the window.
if (config.@"window-height" > 0 and config.@"window-width" > 0) init: { if (config.@"window-height" > 0 and config.@"window-width" > 0) init: {
const scale = rt_surface.getContentScale() catch break :init; const scale = rt_surface.getContentScale() catch break :init;
const height = @max(config.@"window-height" * cell_size.height, 480); const height = @max(config.@"window-height", min_window_height_cells) * cell_size.height;
const width = @max(config.@"window-width" * cell_size.width, 640); const width = @max(config.@"window-width", min_window_width_cells) * cell_size.width;
const width_f32: f32 = @floatFromInt(width); const width_f32: f32 = @floatFromInt(width);
const height_f32: f32 = @floatFromInt(height); const height_f32: f32 = @floatFromInt(height);
@ -1037,6 +1041,9 @@ fn mouseRefreshLinks(
pos_vp: terminal.point.Coordinate, pos_vp: terminal.point.Coordinate,
over_link: bool, over_link: bool,
) !void { ) !void {
// If the position is outside our viewport, do nothing
if (pos.x < 0 or pos.y < 0) return;
self.mouse.link_point = pos_vp; self.mouse.link_point = pos_vp;
if (try self.linkAtPos(pos)) |link| { if (try self.linkAtPos(pos)) |link| {
@ -1312,8 +1319,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };
const x: f64 = x: { const x: f64 = x: {
// Simple x * cell width gives the top-left corner // Simple x * cell width gives the top-left corner, then add padding offset
var x: f64 = @floatFromInt(cursor.x * self.size.cell.width); var x: f64 = @floatFromInt(cursor.x * self.size.cell.width + self.size.padding.left);
// We want the midpoint // We want the midpoint
x += @as(f64, @floatFromInt(self.size.cell.width)) / 2; x += @as(f64, @floatFromInt(self.size.cell.width)) / 2;
@ -1325,8 +1332,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
}; };
const y: f64 = y: { const y: f64 = y: {
// Simple x * cell width gives the top-left corner // Simple y * cell height gives the top-left corner, then add padding offset
var y: f64 = @floatFromInt(cursor.y * self.size.cell.height); var y: f64 = @floatFromInt(cursor.y * self.size.cell.height + self.size.padding.top);
// We want the bottom // We want the bottom
y += @floatFromInt(self.size.cell.height); y += @floatFromInt(self.size.cell.height);
@ -1587,6 +1594,15 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
self.renderer_state.mutex.lock(); self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock(); defer self.renderer_state.mutex.unlock();
// We clear our selection when ANY OF:
// 1. We have an existing preedit
// 2. We have preedit text
if (self.renderer_state.preedit != null or
preedit_ != null)
{
self.setSelection(null) catch {};
}
// We always clear our prior preedit // We always clear our prior preedit
if (self.renderer_state.preedit) |p| { if (self.renderer_state.preedit) |p| {
self.alloc.free(p.codepoints); self.alloc.free(p.codepoints);
@ -1637,6 +1653,31 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
try self.queueRender(); try self.queueRender();
} }
/// Returns true if the given key event would trigger a keybinding
/// if it were to be processed. This is useful for determining if
/// a key event should be sent to the terminal or not.
///
/// Note that this function does not check if the binding itself
/// is performable, only if the key event would trigger a binding.
/// If a performable binding is found and the event is not performable,
/// then Ghosty will act as though the binding does not exist.
pub fn keyEventIsBinding(
self: *Surface,
event: input.KeyEvent,
) bool {
switch (event.action) {
.release => return false,
.press, .repeat => {},
}
// Our keybinding set is either our current nested set (for
// sequences) or the root set.
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// If we have a keybinding for this event then we return true.
return set.getEvent(event) != null;
}
/// Called for any key events. This handles keybindings, encoding and /// Called for any key events. This handles keybindings, encoding and
/// sending to the terminal, etc. /// sending to the terminal, etc.
pub fn keyCallback( pub fn keyCallback(
@ -3525,22 +3566,21 @@ fn dragLeftClickTriple(
const screen = &self.io.terminal.screen; const screen = &self.io.terminal.screen;
const click_pin = self.mouse.left_click_pin.?.*; const click_pin = self.mouse.left_click_pin.?.*;
// Get the word under our current point. If there isn't a word, do nothing. // Get the line selection under our current drag point. If there isn't a
const word = screen.selectLine(.{ .pin = drag_pin }) orelse return; // line, do nothing.
const line = screen.selectLine(.{ .pin = drag_pin }) orelse return;
// Get our selection to grow it. If we don't have a selection, start it now. // Get the selection under our click point. We first try to trim
// We may not have a selection if we started our dbl-click in an area // whitespace if we've selected a word. But if no word exists then
// that had no data, then we dragged our mouse into an area with data. // we select the blank line.
var sel = screen.selectLine(.{ .pin = click_pin }) orelse { const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
try self.setSelection(word); screen.selectLine(.{ .pin = click_pin, .whitespace = null });
return;
};
// Grow our selection var sel = sel_ orelse return;
if (drag_pin.before(click_pin)) { if (drag_pin.before(click_pin)) {
sel.startPtr().* = word.start(); sel.startPtr().* = line.start();
} else { } else {
sel.endPtr().* = word.end(); sel.endPtr().* = line.end();
} }
try self.setSelection(sel); try self.setSelection(sel);
} }
@ -3907,6 +3947,33 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
return false; return false;
}, },
.copy_url_to_clipboard => {
// If the mouse isn't over a link, nothing we can do.
if (!self.mouse.over_link) return false;
const pos = try self.rt_surface.getCursorPos();
if (try self.linkAtPos(pos)) |link_info| {
// Get the URL text from selection
const url_text = (self.io.terminal.screen.selectionString(self.alloc, .{
.sel = link_info[1],
.trim = self.config.clipboard_trim_trailing_spaces,
})) catch |err| {
log.err("error reading url string err={}", .{err});
return false;
};
defer self.alloc.free(url_text);
self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| {
log.err("error copying url to clipboard err={}", .{err});
return true;
};
return true;
}
return false;
},
.paste_from_clipboard => try self.startClipboardRequest( .paste_from_clipboard => try self.startClipboardRequest(
.standard, .standard,
.{ .paste = {} }, .{ .paste = {} },
@ -4032,6 +4099,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{}, {},
), ),
.close_tab => try self.rt_app.performAction(
.{ .surface = self },
.close_tab,
{},
),
inline .previous_tab, inline .previous_tab,
.next_tab, .next_tab,
.last_tab, .last_tab,
@ -4106,6 +4179,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{}, {},
), ),
.toggle_maximize => try self.rt_app.performAction(
.{ .surface = self },
.toggle_maximize,
{},
),
.toggle_fullscreen => try self.rt_app.performAction( .toggle_fullscreen => try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.toggle_fullscreen, .toggle_fullscreen,
@ -4231,6 +4310,7 @@ fn closingAction(action: input.Binding.Action) bool {
return switch (action) { return switch (action) {
.close_surface, .close_surface,
.close_window, .close_window,
.close_tab,
=> true, => true,
else => false, else => false,

View File

@ -70,6 +70,9 @@ pub const Action = union(Key) {
// entry. If the value type is void then only the key needs to be // entry. If the value type is void then only the key needs to be
// added. Ensure the order matches exactly with the Zig code. // added. Ensure the order matches exactly with the Zig code.
/// Quit the application.
quit,
/// Open a new window. The target determines whether properties such /// Open a new window. The target determines whether properties such
/// as font size should be inherited. /// as font size should be inherited.
new_window, new_window,
@ -79,6 +82,9 @@ pub const Action = union(Key) {
/// the tab should be opened in a new window. /// the tab should be opened in a new window.
new_tab, new_tab,
/// Closes the tab belonging to the currently focused split.
close_tab,
/// Create a new split. The value determines the location of the split /// Create a new split. The value determines the location of the split
/// relative to the target. /// relative to the target.
new_split: SplitDirection, new_split: SplitDirection,
@ -86,6 +92,9 @@ pub const Action = union(Key) {
/// Close all open windows. /// Close all open windows.
close_all_windows, close_all_windows,
/// Toggle maximized window state.
toggle_maximize,
/// Toggle fullscreen mode. /// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen, toggle_fullscreen: Fullscreen,
@ -219,10 +228,13 @@ pub const Action = union(Key) {
/// Sync with: ghostty_action_tag_e /// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) { pub const Key = enum(c_int) {
quit,
new_window, new_window,
new_tab, new_tab,
close_tab,
new_split, new_split,
close_all_windows, close_all_windows,
toggle_maximize,
toggle_fullscreen, toggle_fullscreen,
toggle_tab_overview, toggle_tab_overview,
toggle_window_decorations, toggle_window_decorations,

View File

@ -147,12 +147,12 @@ pub const App = struct {
self.core_app.focusEvent(focused); self.core_app.focusEvent(focused);
} }
/// See CoreApp.keyEvent. /// Convert a C key event into a Zig key event.
pub fn keyEvent( fn coreKeyEvent(
self: *App, self: *App,
target: KeyTarget, target: KeyTarget,
event: KeyEvent, event: KeyEvent,
) !bool { ) !?input.KeyEvent {
const action = event.action; const action = event.action;
const keycode = event.keycode; const keycode = event.keycode;
const mods = event.mods; const mods = event.mods;
@ -199,6 +199,11 @@ pub const App = struct {
// This logic only applies to macOS. // This logic only applies to macOS.
if (comptime builtin.os.tag != .macos) break :event_text event.text; if (comptime builtin.os.tag != .macos) break :event_text event.text;
// If we're in a preedit state then we allow it through. This
// allows ctrl sequences that affect IME to work. For example,
// Ctrl+H deletes a character with Japanese input.
if (event.composing) break :event_text event.text;
// If the modifiers are ONLY "control" then we never process // If the modifiers are ONLY "control" then we never process
// the event text because we want to do our own translation so // the event text because we want to do our own translation so
// we can handle ctrl+c, ctrl+z, etc. // we can handle ctrl+c, ctrl+z, etc.
@ -243,7 +248,7 @@ pub const App = struct {
result.text, result.text,
) catch |err| { ) catch |err| {
log.err("error in preedit callback err={}", .{err}); log.err("error in preedit callback err={}", .{err});
return false; return null;
}, },
} }
} else { } else {
@ -251,7 +256,7 @@ pub const App = struct {
.app => {}, .app => {},
.surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err}); log.err("error in preedit callback err={}", .{err});
return false; return null;
}, },
} }
@ -335,7 +340,7 @@ pub const App = struct {
} else .invalid; } else .invalid;
// Build our final key event // Build our final key event
const input_event: input.KeyEvent = .{ return .{
.action = action, .action = action,
.key = key, .key = key,
.physical_key = physical_key, .physical_key = physical_key,
@ -345,24 +350,39 @@ pub const App = struct {
.utf8 = result.text, .utf8 = result.text,
.unshifted_codepoint = unshifted_codepoint, .unshifted_codepoint = unshifted_codepoint,
}; };
}
/// See CoreApp.keyEvent.
pub fn keyEvent(
self: *App,
target: KeyTarget,
event: KeyEvent,
) !bool {
// Convert our C key event into a Zig one.
const input_event: input.KeyEvent = (try self.coreKeyEvent(
target,
event,
)) orelse return false;
// Invoke the core Ghostty logic to handle this input. // Invoke the core Ghostty logic to handle this input.
const effect: CoreSurface.InputEffect = switch (target) { const effect: CoreSurface.InputEffect = switch (target) {
.app => if (self.core_app.keyEvent( .app => if (self.core_app.keyEvent(
self, self,
input_event, input_event,
)) )) .consumed else .ignored,
.consumed
else
.ignored,
.surface => |surface| try surface.core_surface.keyCallback(input_event), .surface => |surface| try surface.core_surface.keyCallback(
input_event,
),
}; };
return switch (effect) { return switch (effect) {
.closed => true, .closed => true,
.ignored => false, .ignored => false,
.consumed => consumed: { .consumed => consumed: {
const is_down = input_event.action == .press or
input_event.action == .repeat;
if (is_down) { if (is_down) {
// If we consume the key then we want to reset the dead // If we consume the key then we want to reset the dead
// key state. // key state.
@ -618,7 +638,7 @@ pub const Surface = struct {
.y = @floatCast(opts.scale_factor), .y = @floatCast(opts.scale_factor),
}, },
.size = .{ .width = 800, .height = 600 }, .size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 }, .cursor_pos = .{ .x = -1, .y = -1 },
.keymap_state = .{}, .keymap_state = .{},
}; };
@ -1332,10 +1352,9 @@ pub const CAPI = struct {
/// Tick the event loop. This should be called whenever the "wakeup" /// Tick the event loop. This should be called whenever the "wakeup"
/// callback is invoked for the runtime. /// callback is invoked for the runtime.
export fn ghostty_app_tick(v: *App) bool { export fn ghostty_app_tick(v: *App) void {
return v.core_app.tick(v) catch |err| err: { v.core_app.tick(v) catch |err| {
log.err("error app tick err={}", .{err}); log.err("error app tick err={}", .{err});
break :err false;
}; };
} }
@ -1372,6 +1391,28 @@ pub const CAPI = struct {
}; };
} }
/// Returns true if the given key event would trigger a binding
/// if it were sent to the surface right now. The "right now"
/// is important because things like trigger sequences are only
/// valid until the next key event.
export fn ghostty_app_key_is_binding(
app: *App,
event: KeyEvent,
) bool {
const core_event = app.coreKeyEvent(
.app,
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
return false;
} orelse {
log.warn("error processing key event", .{});
return false;
};
return app.core_app.keyEventIsBinding(app, core_event);
}
/// Notify the app that the keyboard was changed. This causes the /// Notify the app that the keyboard was changed. This causes the
/// keyboard layout to be reloaded from the OS. /// keyboard layout to be reloaded from the OS.
export fn ghostty_app_keyboard_changed(v: *App) void { export fn ghostty_app_keyboard_changed(v: *App) void {
@ -1592,16 +1633,38 @@ pub const CAPI = struct {
export fn ghostty_surface_key( export fn ghostty_surface_key(
surface: *Surface, surface: *Surface,
event: KeyEvent, event: KeyEvent,
) void { ) bool {
_ = surface.app.keyEvent( return surface.app.keyEvent(
.{ .surface = surface }, .{ .surface = surface },
event.keyEvent(), event.keyEvent(),
) catch |err| { ) catch |err| {
log.warn("error processing key event err={}", .{err}); log.warn("error processing key event err={}", .{err});
return; return false;
}; };
} }
/// Returns true if the given key event would trigger a binding
/// if it were sent to the surface right now. The "right now"
/// is important because things like trigger sequences are only
/// valid until the next key event.
export fn ghostty_surface_key_is_binding(
surface: *Surface,
event: KeyEvent,
) bool {
const core_event = surface.app.coreKeyEvent(
.{ .surface = surface },
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
return false;
} orelse {
log.warn("error processing key event", .{});
return false;
};
return surface.core_surface.keyEventIsBinding(core_event);
}
/// Send raw text to the terminal. This is treated like a paste /// Send raw text to the terminal. This is treated like a paste
/// so this isn't useful for sending escape sequences. For that, /// so this isn't useful for sending escape sequences. For that,
/// individual key input should be used. /// individual key input should be used.
@ -1895,7 +1958,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius( _ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(), CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur-radius"), @intCast(config.@"background-blur".cval()),
); );
} }

View File

@ -35,6 +35,10 @@ pub const App = struct {
app: *CoreApp, app: *CoreApp,
config: Config, config: Config,
/// Flips to true to quit on the next event loop tick. This
/// never goes false and forces the event loop to exit.
quit: bool = false,
/// Mac-specific state. /// Mac-specific state.
darwin: if (Darwin.enabled) Darwin else void, darwin: if (Darwin.enabled) Darwin else void,
@ -124,8 +128,10 @@ pub const App = struct {
glfw.waitEvents(); glfw.waitEvents();
// Tick the terminal app // Tick the terminal app
const should_quit = try self.app.tick(self); try self.app.tick(self);
if (should_quit or self.app.surfaces.items.len == 0) {
// If the tick caused us to quit, then we're done.
if (self.quit or self.app.surfaces.items.len == 0) {
for (self.app.surfaces.items) |surface| { for (self.app.surfaces.items) |surface| {
surface.close(false); surface.close(false);
} }
@ -149,6 +155,8 @@ pub const App = struct {
value: apprt.Action.Value(action), value: apprt.Action.Value(action),
) !void { ) !void {
switch (action) { switch (action) {
.quit => self.quit = true,
.new_window => _ = try self.newSurface(switch (target) { .new_window => _ = try self.newSurface(switch (target) {
.app => null, .app => null,
.surface => |v| v, .surface => |v| v,
@ -210,6 +218,7 @@ pub const App = struct {
.toggle_split_zoom, .toggle_split_zoom,
.present_terminal, .present_terminal,
.close_all_windows, .close_all_windows,
.close_tab,
.toggle_tab_overview, .toggle_tab_overview,
.toggle_window_decorations, .toggle_window_decorations,
.toggle_quick_terminal, .toggle_quick_terminal,
@ -228,6 +237,7 @@ pub const App = struct {
.color_change, .color_change,
.pwd, .pwd,
.config_change, .config_change,
.toggle_maximize,
=> log.info("unimplemented action={}", .{action}), => log.info("unimplemented action={}", .{action}),
} }
} }

View File

@ -36,7 +36,7 @@ const c = @import("c.zig").c;
const version = @import("version.zig"); const version = @import("version.zig");
const inspector = @import("inspector.zig"); const inspector = @import("inspector.zig");
const key = @import("key.zig"); const key = @import("key.zig");
const x11 = @import("x11.zig"); const winproto = @import("winproto.zig");
const testing = std.testing; const testing = std.testing;
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
@ -49,6 +49,9 @@ config: Config,
app: *c.GtkApplication, app: *c.GtkApplication,
ctx: *c.GMainContext, ctx: *c.GMainContext,
/// State and logic for the underlying windowing protocol.
winproto: winproto.App,
/// True if the app was launched with single instance mode. /// True if the app was launched with single instance mode.
single_instance: bool, single_instance: bool,
@ -70,8 +73,10 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// This is set to false when the main loop should exit. /// This is set to false when the main loop should exit.
running: bool = true, running: bool = true,
/// Xkb state (X11 only). Will be null on Wayland. /// If we should retry querying D-Bus for the color scheme with the deprecated
x11_xkb: ?x11.Xkb = null, /// Read method, instead of the recommended ReadOne method. This is kind of
/// nasty to have as struct state but its just a byte...
dbus_color_scheme_retry: bool = true,
/// The base path of the transient cgroup used to put all surfaces /// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled /// into their own cgroup. This is only set if cgroups are enabled
@ -104,42 +109,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
c.gtk_get_micro_version(), c.gtk_get_micro_version(),
}); });
// Disabling Vulkan can improve startup times by hundreds of
// milliseconds on some systems. We don't use Vulkan so we can just
// disable it.
if (version.atLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
// For the remainder of "why" see the 4.14 comment below.
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan");
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional");
} else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
// Older versions of GTK do not support these values so it is safe
// to always set this. Forwards versions are uncertain so we'll have to
// reassess...
//
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
//
// Specific details about values:
// - "opengl" - output OpenGL debug information
// - "gl-disable-gles" - disable GLES, Ghostty can't use GLES
// - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan
// and initializing a Vulkan context was causing a longer delay
// on some systems.
_ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional");
} else {
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
// is an environment that isn't tested well and we don't have a
// good understanding of what we may need to do.
_ = internal_os.setenv("GDK_DEBUG", "vulkan-disable");
}
if (version.atLeast(4, 14, 0)) {
// We need to export GSK_RENDERER to opengl because GTK uses ngl by
// default after 4.14
_ = internal_os.setenv("GSK_RENDERER", "opengl");
}
// Load our configuration // Load our configuration
var config = try Config.load(core_app.alloc); var config = try Config.load(core_app.alloc);
errdefer config.deinit(); errdefer config.deinit();
@ -161,8 +130,111 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
} }
} }
var gdk_debug: struct {
/// output OpenGL debug information
opengl: bool = false,
/// disable GLES, Ghostty can't use GLES
@"gl-disable-gles": bool = false,
@"gl-no-fractional": bool = false,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
/// disable it.
@"vulkan-disable": bool = false,
} = .{
.opengl = config.@"gtk-opengl-debug",
};
var gdk_disable: struct {
@"gles-api": bool = false,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
/// disable it.
vulkan: bool = false,
} = .{};
environment: {
if (version.runtimeAtLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE.
// For the remainder of "why" see the 4.14 comment below.
gdk_disable.@"gles-api" = true;
gdk_disable.vulkan = true;
gdk_debug.@"gl-no-fractional" = true;
break :environment;
}
if (version.runtimeAtLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14.
// Older versions of GTK do not support these values so it is safe
// to always set this. Forwards versions are uncertain so we'll have
// to reassess...
//
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
gdk_debug.@"gl-disable-gles" = true;
gdk_debug.@"gl-no-fractional" = true;
gdk_debug.@"vulkan-disable" = true;
break :environment;
}
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
// is an environment that isn't tested well and we don't have a
// good understanding of what we may need to do.
gdk_debug.@"vulkan-disable" = true;
}
{
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
const writer = fmt.writer();
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_debug)).Struct.fields) |field| {
if (@field(gdk_debug, field.name)) {
if (!first) try writer.writeAll(",");
try writer.writeAll(field.name);
first = false;
}
}
try writer.writeByte(0);
const value = fmt.getWritten();
log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]});
_ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]);
}
{
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
const writer = fmt.writer();
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_disable)).Struct.fields) |field| {
if (@field(gdk_disable, field.name)) {
if (!first) try writer.writeAll(",");
try writer.writeAll(field.name);
first = false;
}
}
try writer.writeByte(0);
const value = fmt.getWritten();
log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]});
_ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]);
}
if (version.runtimeAtLeast(4, 14, 0)) {
switch (config.@"gtk-gsk-renderer") {
.default => {},
else => |renderer| {
// Force the GSK renderer to a specific value. After GTK 4.14 the
// `ngl` renderer is used by default which causes artifacts when
// used with Ghostty so it should be avoided.
log.warn("setting GSK_RENDERER={s}", .{@tagName(renderer)});
_ = internal_os.setenv("GSK_RENDERER", @tagName(renderer));
},
}
}
c.gtk_init(); c.gtk_init();
const display = c.gdk_display_get_default(); const display: *c.GdkDisplay = c.gdk_display_get_default() orelse {
// I'm unsure of any scenario where this happens. Because we don't
// want to litter null checks everywhere, we just exit here.
log.warn("gdk display is null, exiting", .{});
std.posix.exit(1);
};
// If we're using libadwaita, log the version // If we're using libadwaita, log the version
if (adwaita.enabled(&config)) { if (adwaita.enabled(&config)) {
@ -360,42 +432,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
return error.GtkApplicationRegisterFailed; return error.GtkApplicationRegisterFailed;
} }
// Perform all X11 initialization. This ultimately returns the X11 // Setup our windowing protocol logic
// keyboard state but the block does more than that (i.e. setting up var winproto_app = try winproto.App.init(
// WM_CLASS). core_app.alloc,
const x11_xkb: ?x11.Xkb = x11_xkb: { display,
if (comptime !build_options.x11) break :x11_xkb null; app_id,
if (!x11.is_display(display)) break :x11_xkb null; &config,
);
// Set the X11 window class property (WM_CLASS) if are are on an X11 errdefer winproto_app.deinit(core_app.alloc);
// display. log.debug("windowing protocol={s}", .{@tagName(winproto_app)});
//
// Note that we also set the program name here using g_set_prgname.
// This is how the instance name field for WM_CLASS is derived when
// calling gdk_x11_display_set_program_class; there does not seem to be
// a way to set it directly. It does not look like this is being set by
// our other app initialization routines currently, but since we're
// currently deriving its value from x11-instance-name effectively, I
// feel like gating it behind an X11 check is better intent.
//
// This makes the property show up like so when using xprop:
//
// WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty"
//
// Append "-debug" on both when using the debug build.
//
const prgname = if (config.@"x11-instance-name") |pn|
pn
else if (builtin.mode == .Debug)
"ghostty-debug"
else
"ghostty";
c.g_set_prgname(prgname);
c.gdk_x11_display_set_program_class(display, app_id);
// Set up Xkb
break :x11_xkb try x11.Xkb.init(display);
};
// This just calls the `activate` signal but its part of the normal startup // This just calls the `activate` signal but its part of the normal startup
// routine so we just call it, but only if the config allows it (this allows // routine so we just call it, but only if the config allows it (this allows
@ -421,7 +466,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.config = config, .config = config,
.ctx = ctx, .ctx = ctx,
.cursor_none = cursor_none, .cursor_none = cursor_none,
.x11_xkb = x11_xkb, .winproto = winproto_app,
.single_instance = single_instance, .single_instance = single_instance,
// If we are NOT the primary instance, then we never want to run. // If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and // This means that another instance of the GTK app is running and
@ -449,6 +494,8 @@ pub fn terminate(self: *App) void {
} }
self.custom_css_providers.deinit(self.core_app.alloc); self.custom_css_providers.deinit(self.core_app.alloc);
self.winproto.deinit(self.core_app.alloc);
self.config.deinit(); self.config.deinit();
} }
@ -460,13 +507,16 @@ pub fn performAction(
value: apprt.Action.Value(action), value: apprt.Action.Value(action),
) !void { ) !void {
switch (action) { switch (action) {
.quit => self.quit(),
.new_window => _ = try self.newWindow(switch (target) { .new_window => _ = try self.newWindow(switch (target) {
.app => null, .app => null,
.surface => |v| v, .surface => |v| v,
}), }),
.toggle_maximize => self.toggleMaximize(target),
.toggle_fullscreen => self.toggleFullscreen(target, value), .toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target), .new_tab => try self.newTab(target),
.close_tab => try self.closeTab(target),
.goto_tab => self.gotoTab(target, value), .goto_tab => self.gotoTab(target, value),
.move_tab => self.moveTab(target, value), .move_tab => self.moveTab(target, value),
.new_split => try self.newSplit(target, value), .new_split => try self.newSplit(target, value),
@ -482,6 +532,7 @@ pub fn performAction(
.pwd => try self.setPwd(target, value), .pwd => try self.setPwd(target, value),
.present_terminal => self.presentTerminal(target), .present_terminal => self.presentTerminal(target),
.initial_size => try self.setInitialSize(target, value), .initial_size => try self.setInitialSize(target, value),
.size_limit => try self.setSizeLimit(target, value),
.mouse_visibility => self.setMouseVisibility(target, value), .mouse_visibility => self.setMouseVisibility(target, value),
.mouse_shape => try self.setMouseShape(target, value), .mouse_shape => try self.setMouseShape(target, value),
.mouse_over_link => self.setMouseOverLink(target, value), .mouse_over_link => self.setMouseOverLink(target, value),
@ -494,7 +545,6 @@ pub fn performAction(
.close_all_windows, .close_all_windows,
.toggle_quick_terminal, .toggle_quick_terminal,
.toggle_visibility, .toggle_visibility,
.size_limit,
.cell_size, .cell_size,
.secure_input, .secure_input,
.key_sequence, .key_sequence,
@ -522,6 +572,23 @@ fn newTab(_: *App, target: apprt.Target) !void {
} }
} }
fn closeTab(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |v| {
const tab = v.rt_surface.container.tab() orelse {
log.info(
"close_tab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
tab.closeWithConfirmation();
},
}
}
fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void {
switch (target) { switch (target) {
.app => {}, .app => {},
@ -648,6 +715,22 @@ fn controlInspector(
surface.controlInspector(mode); surface.controlInspector(mode);
} }
fn toggleMaximize(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleMaximize invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleMaximize();
},
}
}
fn toggleFullscreen( fn toggleFullscreen(
_: *App, _: *App,
target: apprt.Target, target: apprt.Target,
@ -795,6 +878,23 @@ fn setInitialSize(
} }
} }
fn setSizeLimit(
_: *App,
target: apprt.Target,
value: apprt.action.SizeLimit,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setSizeLimits(.{
.width = value.min_width,
.height = value.min_height,
}, if (value.max_width > 0) .{
.width = value.max_width,
.height = value.max_height,
} else null),
}
}
fn showDesktopNotification( fn showDesktopNotification(
self: *App, self: *App,
target: apprt.Target, target: apprt.Target,
@ -837,9 +937,12 @@ fn configChange(
new_config: *const Config, new_config: *const Config,
) void { ) void {
switch (target) { switch (target) {
// We don't do anything for surface config change events. There .surface => |surface| surface: {
// is nothing to sync with regards to a surface today. const window = surface.rt_surface.container.window() orelse break :surface;
.surface => {}, window.updateConfig(new_config) catch |err| {
log.warn("error updating config for window err={}", .{err});
};
},
.app => { .app => {
// We clone (to take ownership) and update our configuration. // We clone (to take ownership) and update our configuration.
@ -995,7 +1098,28 @@ fn loadRuntimeCss(
unfocused_fill.b, unfocused_fill.b,
}); });
if (version.atLeast(4, 16, 0)) { if (config.@"split-divider-color") |color| {
try writer.print(
\\.terminal-window .notebook separator {{
\\ color: rgb({[r]d},{[g]d},{[b]d});
\\ background: rgb({[r]d},{[g]d},{[b]d});
\\}}
, .{
.r = color.r,
.g = color.g,
.b = color.b,
});
}
if (config.@"window-title-font-family") |font_family| {
try writer.print(
\\.window headerbar {{
\\ font-family: "{[font_family]s}";
\\}}
, .{ .font_family = font_family });
}
if (version.runtimeAtLeast(4, 16, 0)) {
switch (window_theme) { switch (window_theme) {
.ghostty => try writer.print( .ghostty => try writer.print(
\\:root {{ \\:root {{
@ -1008,6 +1132,8 @@ fn loadRuntimeCss(
\\ --overview-bg-color: var(--ghostty-bg); \\ --overview-bg-color: var(--ghostty-bg);
\\ --popover-fg-color: var(--ghostty-fg); \\ --popover-fg-color: var(--ghostty-fg);
\\ --popover-bg-color: var(--ghostty-bg); \\ --popover-bg-color: var(--ghostty-bg);
\\ --window-fg-color: var(--ghostty-fg);
\\ --window-bg-color: var(--ghostty-bg);
\\}} \\}}
\\windowhandle {{ \\windowhandle {{
\\ background-color: var(--headerbar-bg-color); \\ background-color: var(--headerbar-bg-color);
@ -1150,7 +1276,8 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path; self.transient_cgroup_base = path;
} else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"});
// Setup our D-Bus connection for listening to settings changes. // Setup our D-Bus connection for listening to settings changes,
// and asynchronously request the initial color scheme
self.initDbus(); self.initDbus();
// Setup our menu items // Setup our menu items
@ -1158,9 +1285,6 @@ pub fn run(self: *App) !void {
self.initMenu(); self.initMenu();
self.initContextMenu(); self.initContextMenu();
// Setup our initial color scheme
self.colorSchemeEvent(self.getColorScheme());
// On startup, we want to check for configuration errors right away // On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial // so we can show our error window. We also need to setup other initial
// state. // state.
@ -1172,14 +1296,10 @@ pub fn run(self: *App) !void {
_ = c.g_main_context_iteration(self.ctx, 1); _ = c.g_main_context_iteration(self.ctx, 1);
// Tick the terminal app and see if we should quit. // Tick the terminal app and see if we should quit.
const should_quit = try self.core_app.tick(self); try self.core_app.tick(self);
// Check if we must quit based on the current state. // Check if we must quit based on the current state.
const must_quit = q: { const must_quit = q: {
// If we've been told by GTK that we should quit, do so regardless
// of any other setting.
if (should_quit) break :q true;
// If we are configured to always stay running, don't quit. // If we are configured to always stay running, don't quit.
if (!self.config.@"quit-after-last-window-closed") break :q false; if (!self.config.@"quit-after-last-window-closed") break :q false;
@ -1212,6 +1332,22 @@ fn initDbus(self: *App) void {
self, self,
null, null,
); );
// Request the initial color scheme asynchronously.
c.g_dbus_connection_call(
dbus,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"ReadOne",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
dbusColorSchemeCallback,
self,
);
} }
// This timeout function is started when no surfaces are open. It can be // This timeout function is started when no surfaces are open. It can be
@ -1283,6 +1419,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
} }
fn quit(self: *App) void { fn quit(self: *App) void {
// If we're already not running, do nothing.
if (!self.running) return;
// If we have no toplevel windows, then we're done. // If we have no toplevel windows, then we're done.
const list = c.gtk_window_list_toplevels(); const list = c.gtk_window_list_toplevels();
if (list == null) { if (list == null) {
@ -1446,93 +1585,58 @@ fn gtkWindowIsActive(
core_app.focusEvent(false); core_app.focusEvent(false);
} }
/// Call a D-Bus method to determine the current color scheme. If there fn dbusColorSchemeCallback(
/// is any error at any point we'll log the error and return "light" source_object: [*c]c.GObject,
pub fn getColorScheme(self: *App) apprt.ColorScheme { res: ?*c.GAsyncResult,
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); ud: ?*anyopaque,
) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud.?));
const dbus: *c.GDBusConnection = @ptrCast(source_object);
var err: ?*c.GError = null; var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e); defer if (err) |e| c.g_error_free(e);
const value = c.g_dbus_connection_call_sync( if (c.g_dbus_connection_call_finish(dbus, res, &err)) |value| {
dbus_connection, if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
"org.freedesktop.portal.Desktop", var inner: ?*c.GVariant = null;
"/org/freedesktop/portal/desktop", c.g_variant_get(value, "(v)", &inner);
"org.freedesktop.portal.Settings", defer c.g_variant_unref(inner);
"ReadOne", if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1)
c.G_VARIANT_TYPE("(v)"), .dark
c.G_DBUS_CALL_FLAGS_NONE, else
-1, .light);
null, return;
&err,
) orelse {
if (err) |e| {
// If ReadOne is not yet implemented, fall back to deprecated "Read" method
// Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method ReadOne
if (e.code == 19) {
return self.getColorSchemeDeprecated();
} }
// Otherwise, log the error and return .light
log.err("unable to get current color scheme: {s}", .{e.message});
} }
return .light; } else if (err) |e| {
}; // If ReadOne is not yet implemented, fall back to deprecated "Read" method
defer c.g_variant_unref(value); // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method ReadOne
if (self.dbus_color_scheme_retry and e.code == 19) {
self.dbus_color_scheme_retry = false;
c.g_dbus_connection_call(
dbus,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
dbusColorSchemeCallback,
self,
);
return;
}
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { // Otherwise, log the error and return .light
var inner: ?*c.GVariant = null; log.warn("unable to get current color scheme: {s}", .{e.message});
c.g_variant_get(value, "(v)", &inner);
defer c.g_variant_unref(inner);
if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
return if (c.g_variant_get_uint32(inner) == 1) .dark else .light;
}
} }
return .light; // Fall back
} self.colorSchemeEvent(.light);
/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If
/// there is any error at any point we'll log the error and return "light"
fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme {
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
var err: ?*c.GError = null;
defer if (err) |e| c.g_error_free(e);
const value = c.g_dbus_connection_call_sync(
dbus_connection,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings",
"Read",
c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"),
c.G_VARIANT_TYPE("(v)"),
c.G_DBUS_CALL_FLAGS_NONE,
-1,
null,
&err,
) orelse {
if (err) |e| log.err("Read method failed: {s}", .{e.message});
return .light;
};
defer c.g_variant_unref(value);
if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) {
var inner: ?*c.GVariant = null;
c.g_variant_get(value, "(v)", &inner);
defer if (inner) |i| c.g_variant_unref(i);
if (inner) |i| {
const child = c.g_variant_get_child_value(i, 0) orelse {
return .light;
};
defer c.g_variant_unref(child);
const val = c.g_variant_get_uint32(child);
return if (val == 1) .dark else .light;
}
}
return .light;
} }
/// This will be called by D-Bus when the style changes between light & dark. /// This will be called by D-Bus when the style changes between light & dark.
@ -1623,7 +1727,9 @@ fn gtkActionQuit(
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud orelse return)); const self: *App = @ptrCast(@alignCast(ud orelse return));
self.core_app.setQuit(); self.core_app.performAction(self, .quit) catch |err| {
log.err("error quitting err={}", .{err});
};
} }
/// Action sent by the window manager asking us to present a specific surface to /// Action sent by the window manager asking us to present a specific surface to
@ -1695,18 +1801,17 @@ fn initActions(self: *App) void {
} }
} }
/// This sets the self.menu property to the application menu that can be /// Initializes and populates the provided GMenu with sections and actions.
/// shared by all application windows. /// This function is used to set up the application's menu structure, either for
fn initMenu(self: *App) void { /// the main menu button or as a context menu when window decorations are disabled.
const menu = c.g_menu_new(); fn initMenuContent(menu: *c.GMenu) void {
errdefer c.g_object_unref(menu);
{ {
const section = c.g_menu_new(); const section = c.g_menu_new();
defer c.g_object_unref(section); defer c.g_object_unref(section);
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
c.g_menu_append(section, "New Window", "win.new_window"); c.g_menu_append(section, "New Window", "win.new_window");
c.g_menu_append(section, "New Tab", "win.new_tab"); c.g_menu_append(section, "New Tab", "win.new_tab");
c.g_menu_append(section, "Close Tab", "win.close_tab");
c.g_menu_append(section, "Split Right", "win.split_right"); c.g_menu_append(section, "Split Right", "win.split_right");
c.g_menu_append(section, "Split Down", "win.split_down"); c.g_menu_append(section, "Split Down", "win.split_down");
c.g_menu_append(section, "Close Window", "win.close"); c.g_menu_append(section, "Close Window", "win.close");
@ -1721,13 +1826,14 @@ fn initMenu(self: *App) void {
c.g_menu_append(section, "Reload Configuration", "app.reload-config"); c.g_menu_append(section, "Reload Configuration", "app.reload-config");
c.g_menu_append(section, "About Ghostty", "win.about"); c.g_menu_append(section, "About Ghostty", "win.about");
} }
}
// { /// This sets the self.menu property to the application menu that can be
// const section = c.g_menu_new(); /// shared by all application windows.
// defer c.g_object_unref(section); fn initMenu(self: *App) void {
// c.g_menu_append_submenu(menu, "File", @ptrCast(@alignCast(section))); const menu = c.g_menu_new();
// } errdefer c.g_object_unref(menu);
initMenuContent(@ptrCast(menu));
self.menu = menu; self.menu = menu;
} }
@ -1735,7 +1841,13 @@ fn initContextMenu(self: *App) void {
const menu = c.g_menu_new(); const menu = c.g_menu_new();
errdefer c.g_object_unref(menu); errdefer c.g_object_unref(menu);
createContextMenuCopyPasteSection(menu, false); {
const section = c.g_menu_new();
defer c.g_object_unref(section);
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
c.g_menu_append(section, "Copy", "win.copy");
c.g_menu_append(section, "Paste", "win.paste");
}
{ {
const section = c.g_menu_new(); const section = c.g_menu_new();
@ -1753,21 +1865,21 @@ fn initContextMenu(self: *App) void {
c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector");
} }
const section = c.g_menu_new();
defer c.g_object_unref(section);
const submenu = c.g_menu_new();
defer c.g_object_unref(submenu);
initMenuContent(@ptrCast(submenu));
c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu)));
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
self.context_menu = menu; self.context_menu = menu;
} }
fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void {
const section = c.g_menu_new(); const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy"));
defer c.g_object_unref(section); c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0);
c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section)));
// FIXME: Feels really hackish, but disabling sensitivity on this doesn't seems to work(?)
c.g_menu_append(section, "Copy", if (has_selection) "win.copy" else "noop");
c.g_menu_append(section, "Paste", "win.paste");
}
pub fn refreshContextMenu(self: *App, has_selection: bool) void {
c.g_menu_remove(self.context_menu, 0);
createContextMenuCopyPasteSection(self.context_menu, has_selection);
} }
fn isValidAppId(app_id: [:0]const u8) bool { fn isValidAppId(app_id: [:0]const u8) bool {

View File

@ -64,6 +64,7 @@ fn init(
c.gtk_window_set_title(gtk_window, titleText(request)); c.gtk_window_set_title(gtk_window, titleText(request));
c.gtk_window_set_default_size(gtk_window, 550, 275); c.gtk_window_set_default_size(gtk_window, 550, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window");
_ = c.g_signal_connect_data( _ = c.g_signal_connect_data(
window, window,
@ -88,6 +89,8 @@ fn init(
const view = try PrimaryView.init(self, data); const view = try PrimaryView.init(self, data);
self.view = view; self.view = view;
c.gtk_window_set_child(@ptrCast(window), view.root); c.gtk_window_set_child(@ptrCast(window), view.root);
_ = c.gtk_widget_grab_focus(view.buttons.cancel_button);
c.gtk_widget_show(window); c.gtk_widget_show(window);
// Block the main window from input. // Block the main window from input.
@ -103,6 +106,7 @@ fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
const PrimaryView = struct { const PrimaryView = struct {
root: *c.GtkWidget, root: *c.GtkWidget,
text: *c.GtkTextView, text: *c.GtkTextView,
buttons: ButtonsView,
pub fn init(root: *ClipboardConfirmation, data: []const u8) !PrimaryView { pub fn init(root: *ClipboardConfirmation, data: []const u8) !PrimaryView {
// All our widgets // All our widgets
@ -134,7 +138,7 @@ const PrimaryView = struct {
c.gtk_text_view_set_right_margin(@ptrCast(text), 8); c.gtk_text_view_set_right_margin(@ptrCast(text), 8);
c.gtk_text_view_set_monospace(@ptrCast(text), 1); c.gtk_text_view_set_monospace(@ptrCast(text), 1);
return .{ .root = view.root, .text = @ptrCast(text) }; return .{ .root = view.root, .text = @ptrCast(text), .buttons = buttons };
} }
/// Returns the GtkTextBuffer for the data that was unsafe. /// Returns the GtkTextBuffer for the data that was unsafe.
@ -157,6 +161,8 @@ const PrimaryView = struct {
const ButtonsView = struct { const ButtonsView = struct {
root: *c.GtkWidget, root: *c.GtkWidget,
confirm_button: *c.GtkWidget,
cancel_button: *c.GtkWidget,
pub fn init(root: *ClipboardConfirmation) !ButtonsView { pub fn init(root: *ClipboardConfirmation) !ButtonsView {
const cancel_text, const confirm_text = switch (root.pending_req) { const cancel_text, const confirm_text = switch (root.pending_req) {
@ -170,8 +176,8 @@ const ButtonsView = struct {
const confirm_button = c.gtk_button_new_with_label(confirm_text); const confirm_button = c.gtk_button_new_with_label(confirm_text);
errdefer c.g_object_unref(confirm_button); errdefer c.g_object_unref(confirm_button);
// TODO: Focus on the paste button c.gtk_widget_add_css_class(confirm_button, "destructive-action");
// c.gtk_widget_grab_focus(confirm_button); c.gtk_widget_add_css_class(cancel_button, "suggested-action");
// Create our view // Create our view
const view = try View.init(&.{ const view = try View.init(&.{
@ -197,7 +203,7 @@ const ButtonsView = struct {
c.G_CONNECT_DEFAULT, c.G_CONNECT_DEFAULT,
); );
return .{ .root = view.root }; return .{ .root = view.root, .confirm_button = confirm_button, .cancel_button = cancel_button };
} }
fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {

View File

@ -55,6 +55,7 @@ fn init(self: *ConfigErrors, app: *App) !void {
c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_default_size(gtk_window, 600, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window");
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);

View File

@ -25,7 +25,6 @@ const ResizeOverlay = @import("ResizeOverlay.zig");
const inspector = @import("inspector.zig"); const inspector = @import("inspector.zig");
const gtk_key = @import("key.zig"); const gtk_key = @import("key.zig");
const c = @import("c.zig").c; const c = @import("c.zig").c;
const x11 = @import("x11.zig");
const log = std.log.scoped(.gtk_surface); const log = std.log.scoped(.gtk_surface);
@ -347,6 +346,11 @@ cursor: ?*c.GdkCursor = null,
/// pass it to GTK. /// pass it to GTK.
title_text: ?[:0]const u8 = null, title_text: ?[:0]const u8 = null,
/// Our current working directory. We use this value for setting tooltips in
/// the headerbar subtitle if we have focus. When set, the text in this buf
/// will be null-terminated because we need to pass it to GTK.
pwd: ?[:0]const u8 = null,
/// The timer used to delay title updates in order to prevent flickering. /// The timer used to delay title updates in order to prevent flickering.
update_title_timer: ?c.guint = null, update_title_timer: ?c.guint = null,
@ -364,10 +368,9 @@ cursor_pos: apprt.CursorPos,
inspector: ?*inspector.Inspector = null, inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions. /// Key input states. See gtkKeyPressed for detailed descriptions.
in_keypress: bool = false, in_keyevent: bool = false,
im_context: *c.GtkIMContext, im_context: *c.GtkIMContext,
im_composing: bool = false, im_composing: bool = false,
im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined, im_buf: [128]u8 = undefined,
im_len: u7 = 0, im_len: u7 = 0,
@ -492,6 +495,17 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
c.gtk_widget_set_focusable(gl_area, 1); c.gtk_widget_set_focusable(gl_area, 1);
c.gtk_widget_set_focus_on_click(gl_area, 1); c.gtk_widget_set_focus_on_click(gl_area, 1);
// Set up to handle items being dropped on our surface. Files can be dropped
// from Nautilus and strings can be dropped from many programs.
const drop_target = c.gtk_drop_target_new(c.G_TYPE_INVALID, c.GDK_ACTION_COPY);
errdefer c.g_object_unref(drop_target);
var drop_target_types = [_]c.GType{
c.gdk_file_list_get_type(),
c.G_TYPE_STRING,
};
c.gtk_drop_target_set_gtypes(drop_target, @ptrCast(&drop_target_types), drop_target_types.len);
c.gtk_widget_add_controller(@ptrCast(overlay), @ptrCast(drop_target));
// Inherit the parent's font size if we have a parent. // Inherit the parent's font size if we have a parent.
const font_size: ?font.face.DesiredSize = font_size: { const font_size: ?font.face.DesiredSize = font_size: {
if (!app.config.@"window-inherit-font-size") break :font_size null; if (!app.config.@"window-inherit-font-size") break :font_size null;
@ -545,7 +559,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
.font_size = font_size, .font_size = font_size,
.init_config = init_config, .init_config = init_config,
.size = .{ .width = 800, .height = 600 }, .size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 }, .cursor_pos = .{ .x = -1, .y = -1 },
.im_context = im_context, .im_context = im_context,
.cgroup_path = cgroup_path, .cgroup_path = cgroup_path,
}; };
@ -574,6 +588,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
_ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(&gtkInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(&gtkInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(&gtkInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(&gtkInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(&gtkInputCommit), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(&gtkInputCommit), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(drop_target, "drop", c.G_CALLBACK(&gtkDrop), self, null, c.G_CONNECT_DEFAULT);
} }
fn realize(self: *Surface) !void { fn realize(self: *Surface) !void {
@ -618,9 +633,6 @@ fn realize(self: *Surface) !void {
try self.core_surface.setFontSize(size); try self.core_surface.setFontSize(size);
} }
// Set the initial color scheme
try self.core_surface.colorSchemeCallback(self.app.getColorScheme());
// Note we're realized // Note we're realized
self.realized = true; self.realized = true;
} }
@ -628,6 +640,7 @@ fn realize(self: *Surface) !void {
pub fn deinit(self: *Surface) void { pub fn deinit(self: *Surface) void {
self.init_config.deinit(self.app.core_app.alloc); self.init_config.deinit(self.app.core_app.alloc);
if (self.title_text) |title| self.app.core_app.alloc.free(title); if (self.title_text) |title| self.app.core_app.alloc.free(title);
if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd);
// We don't allocate anything if we aren't realized. // We don't allocate anything if we aren't realized.
if (!self.realized) return; if (!self.realized) return;
@ -840,6 +853,28 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void
); );
} }
pub fn setSizeLimits(self: *const Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
// There's no support for setting max size at the moment.
_ = max_;
// If we are within a split, do not set the size.
if (self.container.split() != null) return;
// This operation only makes sense if we're within a window view
// hierarchy and we're the first tab in the window.
const window = self.container.window() orelse return;
if (window.notebook.nPages() > 1) return;
// Note: this doesn't properly take into account the window decorations.
// I'm not currently sure how to do that.
c.gtk_widget_set_size_request(
@ptrCast(window.window),
@intCast(min.width),
@intCast(min.height),
);
}
pub fn grabFocus(self: *Surface) void { pub fn grabFocus(self: *Surface) void {
if (self.container.tab()) |tab| { if (self.container.tab()) |tab| {
// If any other surface was focused and zoomed in, set it to non zoomed in // If any other surface was focused and zoomed in, set it to non zoomed in
@ -876,7 +911,7 @@ fn updateTitleLabels(self: *Surface) void {
// I don't know a way around this yet. I've tried re-hiding the // I don't know a way around this yet. I've tried re-hiding the
// cursor after setting the title but it doesn't work, I think // cursor after setting the title but it doesn't work, I think
// due to some gtk event loop things... // due to some gtk event loop things...
c.gtk_window_set_title(window.window, title.ptr); window.setTitle(title);
} }
} }
} }
@ -929,11 +964,27 @@ pub fn getTitle(self: *Surface) ?[:0]const u8 {
return null; return null;
} }
/// Set the current working directory of the surface.
///
/// In addition, update the tab's tooltip text, and if we are the focused child,
/// update the subtitle of the containing window.
pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void { pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void {
// If we have a tab and are the focused child, then we have to update the tab
if (self.container.tab()) |tab| { if (self.container.tab()) |tab| {
tab.setTooltipText(pwd); tab.setTooltipText(pwd);
if (tab.focus_child == self) {
if (self.container.window()) |window| {
if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd);
}
}
} }
const alloc = self.app.core_app.alloc;
// Failing to set the surface's current working directory is not a big
// deal since we just used our slice parameter which is the same value.
if (self.pwd) |old| alloc.free(old);
self.pwd = alloc.dupeZ(u8, pwd) catch null;
} }
pub fn setMouseShape( pub fn setMouseShape(
@ -1080,6 +1131,13 @@ pub fn setClipboardString(
if (!confirm) { if (!confirm) {
const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type); const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type);
c.gdk_clipboard_set_text(clipboard, val.ptr); c.gdk_clipboard_set_text(clipboard, val.ptr);
// We only toast if we are copying to the standard clipboard.
if (clipboard_type == .standard and
self.app.config.@"app-notifications".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
}
return; return;
} }
@ -1217,7 +1275,7 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void {
}; };
c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect);
self.app.refreshContextMenu(self.core_surface.hasSelection()); self.app.refreshContextMenu(window.window, self.core_surface.hasSelection());
c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu)));
} }
@ -1321,6 +1379,12 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
return; return;
}; };
if (self.container.window()) |window| {
window.winproto.resizeEvent() catch |err| {
log.warn("failed to notify window protocol of resize={}", .{err});
};
}
self.resize_overlay.maybeShow(); self.resize_overlay.maybeShow();
} }
} }
@ -1426,31 +1490,37 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y), .y = @floatCast(scaled.y),
}; };
// When the GLArea is resized under the mouse, GTK issues a mouse motion // There seem to be at least two cases where GTK issues a mouse motion
// event. This has the unfortunate side effect of causing focus to potentially // event without the cursor actually moving:
// change when `focus-follows-mouse` is enabled. To prevent this, we check // 1. GLArea is resized under the mouse. This has the unfortunate
// if the cursor is still in the same place as the last event and only grab // side effect of causing focus to potentially change when
// focus if it has moved. // `focus-follows-mouse` is enabled.
// 2. The window title is updated. This can cause the mouse to unhide
// incorrectly when hide-mouse-when-typing is enabled.
// To prevent incorrect behavior, we'll only grab focus and
// continue with callback logic if the cursor has actually moved.
const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1; @abs(self.cursor_pos.y - pos.y) < 1;
// If we don't have focus, and we want it, grab it. if (!is_cursor_still) {
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); // If we don't have focus, and we want it, grab it.
if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
self.grabFocus(); if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
self.grabFocus();
}
// Our pos changed, update
self.cursor_pos = pos;
// Get our modifiers
const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods);
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};
} }
// Our pos changed, update
self.cursor_pos = pos;
// Get our modifiers
const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods);
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};
} }
fn gtkMouseLeave( fn gtkMouseLeave(
@ -1530,30 +1600,36 @@ fn gtkKeyReleased(
)) 1 else 0; )) 1 else 0;
} }
/// Key press event. This is where we do ALL of our key handling, /// Key press event (press or release).
/// translation to keyboard layouts, dead key handling, etc. Key handling
/// is complicated so this comment will explain what's going on.
/// ///
/// At a high level, we want to construct an `input.KeyEvent` and /// At a high level, we want to construct an `input.KeyEvent` and
/// pass that to `keyCallback`. At a low level, this is more complicated /// pass that to `keyCallback`. At a low level, this is more complicated
/// than it appears because we need to construct all of this information /// than it appears because we need to construct all of this information
/// and its not given to us. /// and its not given to us.
/// ///
/// For press events, we run the keypress through the input method context /// For all events, we run the GdkEvent through the input method context.
/// in order to determine if we're in a dead key state, completed unicode /// This allows the input method to capture the event and trigger
/// char, etc. This all happens through various callbacks: preedit, commit, /// callbacks such as preedit, commit, etc.
/// etc. These inspect "in_keypress" if they have to and set some instance
/// state.
/// ///
/// We then take all of the information in order to determine if we have /// There are a couple important aspects to the prior paragraph: we must
/// send ALL events through the input method context. This is because
/// input methods use both key press and key release events to determine
/// the state of the input method. For example, fcitx uses key release
/// events on modifiers (i.e. ctrl+shift) to switch the input method.
///
/// We set some state to note we're in a key event (self.in_keyevent)
/// because some of the input method callbacks change behavior based on
/// this state. For example, we don't want to send character events
/// like "a" via the input "commit" event if we're actively processing
/// a keypress because we'd lose access to the keycode information.
/// However, a "commit" event may still happen outside of a keypress
/// event from e.g. a tablet or on-screen keyboard.
///
/// Finally, we take all of the information in order to determine if we have
/// a unicode character or if we have to map the keyval to a code to /// a unicode character or if we have to map the keyval to a code to
/// get the underlying logical key, etc. /// get the underlying logical key, etc.
/// ///
/// Finally, we can emit the keyCallback. /// Then we can emit the keyCallback.
///
/// Note we ALSO have an IMContext attached directly to the widget
/// which can emit preedit and commit callbacks. But, if we're not
/// in a keypress, we let those automatically work.
pub fn keyEvent( pub fn keyEvent(
self: *Surface, self: *Surface,
action: input.Action, action: input.Action,
@ -1562,26 +1638,15 @@ pub fn keyEvent(
keycode: c.guint, keycode: c.guint,
gtk_mods: c.GdkModifierType, gtk_mods: c.GdkModifierType,
) bool { ) bool {
// log.warn("GTKIM: keyEvent action={}", .{action});
const event = c.gtk_event_controller_get_current_event( const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key), @ptrCast(ec_key),
) orelse return false; ) orelse return false;
const keyval_unicode = c.gdk_keyval_to_unicode(keyval); // The block below is all related to input method handling. See the function
// comment for some high level details and then the comments within
// Get the unshifted unicode value of the keyval. This is used // the block for more specifics.
// by the Kitty keyboard protocol. {
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We always reset our committed text when ending a keypress so that
// future keypresses don't think we have a commit event.
defer self.im_len = 0;
// We only want to send the event through the IM context if we're a press
if (action == .press or action == .repeat) {
// This can trigger an input method so we need to notify the im context // This can trigger an input method so we need to notify the im context
// where the cursor is so it can render the dropdowns in the correct // where the cursor is so it can render the dropdowns in the correct
// place. // place.
@ -1593,41 +1658,94 @@ pub fn keyEvent(
.height = 1, .height = 1,
}); });
// We mark that we're in a keypress event. We use this in our // Pass the event through the IM controller. This will return true
// IM commit callback to determine if we need to send a char callback // if the input method handled the event.
// to the core surface or not. //
self.in_keypress = true; // Confusingly, not all events handled by the input method result
defer self.in_keypress = false; // in this returning true so we have to maintain some local state to
// find those and in one case we simply lose information.
//
// - If we change the input method via keypress while we have preedit
// text, the input method will commit the pending text but will not
// mark it as handled. We use the `was_composing` variable to detect
// this case.
//
// - If we switch input methods (i.e. via ctrl+shift with fcitx),
// the input method will handle the key release event but will not
// mark it as handled. I don't know any way to detect this case so
// it will result in a key event being sent to the key callback.
// For Kitty text encoding, this will result in modifiers being
// triggered despite being technically consumed. At the time of
// writing, both Kitty and Alacritty have the same behavior. I
// know of no way to fix this.
const was_composing = self.im_composing;
const im_handled = filter: {
// We note that we're in a keypress because we want some logic to
// depend on this. For example, we don't want to send character events
// like "a" via the input "commit" event if we're actively processing
// a keypress because we'd lose access to the keycode information.
self.in_keyevent = true;
defer self.in_keyevent = false;
break :filter c.gtk_im_context_filter_keypress(
self.im_context,
event,
) != 0;
};
// log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{
// im_handled,
// self.im_len,
// self.im_composing,
// });
// Pass the event through the IM controller to handle dead key states. // If the input method handled the event, you would think we would
// Filter is true if the event was handled by the IM controller. // never proceed with key encoding for Ghostty but that is not the
const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0; // case. Input methods will handle basic character encoding like
// log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing }); // typing "a" and we want to associate that with the key event.
// So we have to check additional state to determine if we exit.
if (im_handled) {
// If we are composing then we're in a preedit state and do
// not want to encode any keys. For example: type a deadkey
// such as single quote on a US international keyboard layout.
if (self.im_composing) return true;
// If this is a dead key, then we're composing a character and // If we were composing and now we're not it means that we committed
// we need to set our proper preedit state. // the text. We also don't want to encode a key event for this.
if (self.im_composing) preedit: { // Example: enable Japanese input method, press "konn" and then
const text = self.im_buf[0..self.im_len]; // press enter. The final enter should not be encoded and "konn"
self.core_surface.preeditCallback(text) catch |err| { // (in hiragana) should be written as "こん".
log.err("error in preedit callback err={}", .{err}); if (was_composing) return true;
break :preedit;
};
// If we're composing then we don't want to send the key // Not composing and our input method buffer is empty. This could
// event to the core surface so we always return immediately. // mean that the input method reacted to this event by activating
if (im_handled) return true; // an onscreen keyboard or something equivalent. We don't know.
} else { // But the input method handled it and didn't give us text so
// If we aren't composing, then we set our preedit to // we will just assume we should not encode this. This handles a
// empty no matter what. // real scenario when ibus starts the emoji input method
self.core_surface.preeditCallback(null) catch {}; // (super+.).
if (self.im_len == 0) return true;
// If the IM handled this and we have no text, then we just
// return because this probably just changed the input method
// or something.
if (im_handled and self.im_len == 0) return true;
} }
// At this point, for the sake of explanation of internal state:
// it is possible that im_len > 0 and im_composing == false. This
// means that we received a commit event from the input method that
// we want associated with the key event. This is common: its how
// basic character translation for simple inputs like "a" work.
} }
// We always reset the length of the im buffer. There's only one scenario
// we reach this point with im_len > 0 and that's if we received a commit
// event from the input method. We don't want to keep that state around
// since we've handled it here.
defer self.im_len = 0;
// Get the keyvals for this event.
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We want to get the physical unmapped key to process physical keybinds. // We want to get the physical unmapped key to process physical keybinds.
// (These are keybinds explicitly marked as requesting physical mapping). // (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| { const physical_key = keycode: for (input.keycodes.entries) |entry| {
@ -1636,11 +1754,10 @@ pub fn keyEvent(
// Get our modifier for the event // Get our modifier for the event
const mods: input.Mods = gtk_key.eventMods( const mods: input.Mods = gtk_key.eventMods(
@ptrCast(self.gl_area),
event, event,
physical_key, physical_key,
gtk_mods, gtk_mods,
if (self.app.x11_xkb) |*xkb| xkb else null, &self.app.winproto,
); );
// Get our consumed modifiers // Get our consumed modifiers
@ -1761,12 +1878,11 @@ fn gtkInputPreeditStart(
_: *c.GtkIMContext, _: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
//log.debug("preedit start", .{}); // log.warn("GTKIM: preedit start", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// Mark that we are now composing a string with a dead key state. // Start our composing state for the input method and reset our
// We'll record the string in the preedit-changed callback. // input buffer to empty.
self.im_composing = true; self.im_composing = true;
self.im_len = 0; self.im_len = 0;
} }
@ -1775,54 +1891,35 @@ fn gtkInputPreeditChanged(
ctx: *c.GtkIMContext, ctx: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
// log.warn("GTKIM: preedit change", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
// If there's buffered character, send the characters directly to the surface.
if (self.im_composing and self.im_commit_buffered) {
defer self.im_commit_buffered = false;
defer self.im_len = 0;
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
.physical_key = .invalid,
.mods = .{},
.consumed_mods = .{},
.composing = false,
.utf8 = self.im_buf[0..self.im_len],
}) catch |err| {
log.err("error in key callback err={}", .{err});
return;
};
}
if (!self.in_keypress) return;
// Get our pre-edit string that we'll use to show the user. // Get our pre-edit string that we'll use to show the user.
var buf: [*c]u8 = undefined; var buf: [*c]u8 = undefined;
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null); _ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
defer c.g_free(buf); defer c.g_free(buf);
const str = std.mem.sliceTo(buf, 0); const str = std.mem.sliceTo(buf, 0);
// If our string becomes empty we ignore this. This can happen after // Update our preedit state in Ghostty core
// a commit event when the preedit is being cleared and we don't want self.core_surface.preeditCallback(str) catch |err| {
// to set im_len to zero. This is safe because preeditstart always sets log.err("error in preedit callback err={}", .{err});
// im_len to zero. };
if (str.len == 0) return;
// Copy the preedit string into the im_buf. This is safe because
// commit will always overwrite this.
self.im_len = @intCast(@min(self.im_buf.len, str.len));
@memcpy(self.im_buf[0..self.im_len], str);
} }
fn gtkInputPreeditEnd( fn gtkInputPreeditEnd(
_: *c.GtkIMContext, _: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
//log.debug("preedit end", .{}); // log.warn("GTKIM: preedit end", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// End our composing state for GTK, allowing us to commit the text.
self.im_composing = false; self.im_composing = false;
// End our preedit state in Ghostty core
self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
} }
fn gtkInputCommit( fn gtkInputCommit(
@ -1830,38 +1927,45 @@ fn gtkInputCommit(
bytes: [*:0]u8, bytes: [*:0]u8,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
// log.warn("GTKIM: input commit", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
const str = std.mem.sliceTo(bytes, 0); const str = std.mem.sliceTo(bytes, 0);
// If we're in a key event, then we want to buffer the commit so // If we're in a keyEvent (i.e. a keyboard event) and we're not composing,
// that we can send the proper keycallback followed by the char // then this is just a normal key press resulting in UTF-8 text. We
// callback. // want the keyEvent to handle this so that the UTF-8 text can be associated
if (self.in_keypress) { // with a keyboard event.
if (str.len <= self.im_buf.len) { if (!self.im_composing and self.in_keyevent) {
@memcpy(self.im_buf[0..str.len], str); if (str.len > self.im_buf.len) {
self.im_len = @intCast(str.len);
// If composing is done and character should be committed,
// It should be committed in preedit callback.
if (self.im_composing) {
self.im_commit_buffered = true;
}
// log.debug("input commit len={}", .{self.im_len});
} else {
log.warn("not enough buffer space for input method commit", .{}); log.warn("not enough buffer space for input method commit", .{});
return;
} }
// Copy our committed text to the buffer
@memcpy(self.im_buf[0..str.len], str);
self.im_len = @intCast(str.len);
// log.debug("input commit len={}", .{self.im_len});
return; return;
} }
// This prevents staying in composing state after commit even though // If we reach this point from above it means we're composing OR
// input method has changed. // not in a keypress. In either case, we want to commit the text
// given to us because that's what GTK is asking us to do. If we're
// not in a keypress it means that this commit came via a non-keyboard
// event (i.e. on-screen keyboard, tablet of some kind, etc.).
// Committing ends composing state
self.im_composing = false; self.im_composing = false;
// We're not in a keypress, so this was sent from an on-screen emoji // End our preedit state. Well-behaved input methods do this for us
// keyboard or something like that. Send the characters directly to // by triggering a preedit-end event but some do not (ibus 1.5.29).
// the surface. self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
// Send the text to the core surface, associated with no key (an
// invalid key, which should produce no PTY encoding).
_ = self.core_surface.keyCallback(.{ _ = self.core_surface.keyCallback(.{
.action = .press, .action = .press,
.key = .invalid, .key = .invalid,
@ -1871,7 +1975,7 @@ fn gtkInputCommit(
.composing = false, .composing = false,
.utf8 = str, .utf8 = str,
}) catch |err| { }) catch |err| {
log.err("error in key callback err={}", .{err}); log.warn("error in key callback err={}", .{err});
return; return;
}; };
} }
@ -1889,6 +1993,12 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo
self.unfocused_widget = null; self.unfocused_widget = null;
} }
if (self.pwd) |pwd| {
if (self.container.window()) |window| {
if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd);
}
}
// Notify our surface // Notify our surface
self.core_surface.focusCallback(true) catch |err| { self.core_surface.focusCallback(true) catch |err| {
log.err("error in focus callback err={}", .{err}); log.err("error in focus callback err={}", .{err});
@ -2018,3 +2128,95 @@ pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void {
pub fn toggleSplitZoom(self: *Surface) void { pub fn toggleSplitZoom(self: *Surface) void {
self.setSplitZoom(!self.zoomed_in); self.setSplitZoom(!self.zoomed_in);
} }
/// Handle items being dropped on our surface.
fn gtkDrop(
_: *c.GtkDropTarget,
value: *c.GValue,
x: f64,
y: f64,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
_ = x;
_ = y;
const self = userdataSelf(ud.?);
const alloc = self.app.core_app.alloc;
if (g_value_holds(value, c.G_TYPE_BOXED)) {
var data = std.ArrayList(u8).init(alloc);
defer data.deinit();
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
.child_writer = data.writer(),
};
const writer = shell_escape_writer.writer();
const fl: *c.GdkFileList = @ptrCast(c.g_value_get_boxed(value));
var l = c.gdk_file_list_get_files(fl);
while (l != null) : (l = l.*.next) {
const file: *c.GFile = @ptrCast(l.*.data);
const path = c.g_file_get_path(file) orelse continue;
writer.writeAll(std.mem.span(path)) catch |err| {
log.err("unable to write path to buffer: {}", .{err});
continue;
};
writer.writeAll("\n") catch |err| {
log.err("unable to write to buffer: {}", .{err});
continue;
};
}
const string = data.toOwnedSliceSentinel(0) catch |err| {
log.err("unable to convert to a slice: {}", .{err});
return 1;
};
defer alloc.free(string);
self.doPaste(string);
return 1;
}
if (g_value_holds(value, c.G_TYPE_STRING)) {
if (c.g_value_get_string(value)) |string| {
self.doPaste(std.mem.span(string));
}
return 1;
}
return 1;
}
fn doPaste(self: *Surface, data: [:0]const u8) void {
if (data.len == 0) return;
self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
=> {
ClipboardConfirmationWindow.create(
self.app,
data,
&self.core_surface,
.paste,
) catch |window_err| {
log.err("failed to create clipboard confirmation window err={}", .{window_err});
};
},
error.OutOfMemory,
error.NoSpaceLeft,
=> log.err("failed to complete clipboard request err={}", .{err}),
};
}
/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's
/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it.
fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool {
if (value_) |value| {
if (value.*.g_type == g_type) return true;
return c.g_type_check_value_holds(value, g_type) != 0;
}
return false;
}

View File

@ -121,10 +121,63 @@ pub fn remove(self: *Tab) void {
self.window.closeTab(self); self.window.closeTab(self);
} }
pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { /// Helper function to check if any surface in the split hierarchy needs close confirmation
fn needsConfirm(elem: Surface.Container.Elem) bool {
return switch (elem) {
.surface => |s| s.core_surface.needsConfirmQuit(),
.split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right),
};
}
/// Close the tab, asking for confirmation if any surface requests it.
pub fn closeWithConfirmation(tab: *Tab) void {
switch (tab.elem) {
.surface => |s| s.close(s.core_surface.needsConfirmQuit()),
.split => |s| {
if (needsConfirm(s.top_left) or needsConfirm(s.bottom_right)) {
const alert = c.gtk_message_dialog_new(
tab.window.window,
c.GTK_DIALOG_MODAL,
c.GTK_MESSAGE_QUESTION,
c.GTK_BUTTONS_YES_NO,
"Close this tab?",
);
c.gtk_message_dialog_format_secondary_text(
@ptrCast(alert),
"All terminal sessions in this tab will be terminated.",
);
// We want the "yes" to appear destructive.
const yes_widget = c.gtk_dialog_get_widget_for_response(
@ptrCast(alert),
c.GTK_RESPONSE_YES,
);
c.gtk_widget_add_css_class(yes_widget, "destructive-action");
// We want the "no" to be the default action
c.gtk_dialog_set_default_response(
@ptrCast(alert),
c.GTK_RESPONSE_NO,
);
_ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(&gtkTabCloseConfirmation), tab, null, c.G_CONNECT_DEFAULT);
c.gtk_widget_show(alert);
return;
}
tab.remove();
},
}
}
fn gtkTabCloseConfirmation(
alert: *c.GtkMessageDialog,
response: c.gint,
ud: ?*anyopaque,
) callconv(.C) void {
const tab: *Tab = @ptrCast(@alignCast(ud)); const tab: *Tab = @ptrCast(@alignCast(ud));
const window = tab.window; c.gtk_window_destroy(@ptrCast(alert));
window.closeTab(tab); if (response != c.GTK_RESPONSE_YES) return;
tab.remove();
} }
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
@ -135,17 +188,3 @@ fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
const tab: *Tab = @ptrCast(@alignCast(ud)); const tab: *Tab = @ptrCast(@alignCast(ud));
tab.destroy(tab.window.app.core_app.alloc); tab.destroy(tab.window.app.core_app.alloc);
} }
pub fn gtkTabClick(
gesture: *c.GtkGestureClick,
_: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Tab = @ptrCast(@alignCast(ud));
const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture));
if (gtk_button == c.GDK_BUTTON_MIDDLE) {
self.remove();
}
}

View File

@ -25,6 +25,7 @@ const gtk_key = @import("key.zig");
const Notebook = @import("notebook.zig").Notebook; const Notebook = @import("notebook.zig").Notebook;
const HeaderBar = @import("headerbar.zig").HeaderBar; const HeaderBar = @import("headerbar.zig").HeaderBar;
const version = @import("version.zig"); const version = @import("version.zig");
const winproto = @import("winproto.zig");
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
@ -36,7 +37,7 @@ window: *c.GtkWindow,
/// The header bar for the window. This is possibly null since it can be /// The header bar for the window. This is possibly null since it can be
/// disabled using gtk-titlebar. This is either an AdwHeaderBar or /// disabled using gtk-titlebar. This is either an AdwHeaderBar or
/// GtkHeaderBar depending on if adw is enabled and linked. /// GtkHeaderBar depending on if adw is enabled and linked.
header: ?HeaderBar, headerbar: HeaderBar,
/// The tab overview for the window. This is possibly null since there is no /// The tab overview for the window. This is possibly null since there is no
/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0).
@ -55,6 +56,9 @@ toast_overlay: ?*c.GtkWidget,
/// See adwTabOverviewOpen for why we have this. /// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c.guint = null, adw_tab_overview_focus_timer: ?c.guint = null,
/// State and logic for windowing protocol for a window.
winproto: winproto.Window,
pub fn create(alloc: Allocator, app: *App) !*Window { pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize // Allocate a fixed pointer for our window. We try to minimize
// allocations but windows and other GUI requirements are so minimal // allocations but windows and other GUI requirements are so minimal
@ -74,11 +78,12 @@ pub fn init(self: *Window, app: *App) !void {
self.* = .{ self.* = .{
.app = app, .app = app,
.window = undefined, .window = undefined,
.header = null, .headerbar = undefined,
.tab_overview = null, .tab_overview = null,
.notebook = undefined, .notebook = undefined,
.context_menu = undefined, .context_menu = undefined,
.toast_overlay = undefined, .toast_overlay = undefined,
.winproto = .none,
}; };
// Create the window // Create the window
@ -99,6 +104,7 @@ pub fn init(self: *Window, app: *App) !void {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_title(gtk_window, "Ghostty");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window");
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window"); c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window");
// GTK4 grabs F10 input by default to focus the menubar icon. We want // GTK4 grabs F10 input by default to focus the menubar icon. We want
@ -114,11 +120,6 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty"); c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty");
} }
// Remove the window's background if any of the widgets need to be transparent
if (app.config.@"background-opacity" < 1) {
c.gtk_widget_remove_css_class(@ptrCast(window), "background");
}
// Create our box which will hold our widgets in the main content area. // Create our box which will hold our widgets in the main content area.
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
@ -150,82 +151,66 @@ pub fn init(self: *Window, app: *App) !void {
break :overview tab_overview; break :overview tab_overview;
} else null; } else null;
// gtk-titlebar can be used to disable the header bar (but keep // gtk-titlebar can be used to disable the header bar (but keep the window
// the window manager's decorations). We create this no matter if we // manager's decorations). We create this no matter if we are decorated or
// are decorated or not because we can have a keybind to toggle the // not because we can have a keybind to toggle the decorations.
// decorations. self.headerbar.init();
if (app.config.@"gtk-titlebar") {
const header = HeaderBar.init(self);
// If we are not decorated then we hide the titlebar. {
header.setVisible(app.config.@"window-decoration"); const btn = c.gtk_menu_button_new();
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
{ c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic");
const btn = c.gtk_menu_button_new(); c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu)));
c.gtk_widget_set_tooltip_text(btn, "Main Menu"); self.headerbar.packEnd(btn);
c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic");
c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu)));
header.packEnd(btn);
}
// If we're using an AdwWindow then we can support the tab overview.
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0));
const btn = switch (app.config.@"gtk-tabs-location") {
.top, .bottom, .left, .right => btn: {
const btn = c.gtk_toggle_button_new();
c.gtk_widget_set_tooltip_text(btn, "View Open Tabs");
c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic");
_ = c.g_object_bind_property(
btn,
"active",
tab_overview,
"open",
c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE,
);
break :btn btn;
},
.hidden => btn: {
const btn = c.adw_tab_button_new();
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view);
c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
break :btn btn;
},
};
c.gtk_widget_set_focus_on_click(btn, c.FALSE);
header.packEnd(btn);
}
{
const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic");
c.gtk_widget_set_tooltip_text(btn, "New Tab");
_ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(&gtkTabNewClick), self, null, c.G_CONNECT_DEFAULT);
header.packStart(btn);
}
self.header = header;
} }
// If we are disabling decorations then disable them right away. // If we're using an AdwWindow then we can support the tab overview.
if (!app.config.@"window-decoration") { if (self.tab_overview) |tab_overview| {
c.gtk_window_set_decorated(gtk_window, 0); if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0));
const btn = switch (app.config.@"gtk-tabs-location") {
.top, .bottom, .left, .right => btn: {
const btn = c.gtk_toggle_button_new();
c.gtk_widget_set_tooltip_text(btn, "View Open Tabs");
c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic");
_ = c.g_object_bind_property(
btn,
"active",
tab_overview,
"open",
c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE,
);
// Fix any artifacting that may occur in window corners. break :btn btn;
if (app.config.@"gtk-titlebar") { },
c.gtk_widget_add_css_class(window, "without-window-decoration-and-with-titlebar");
} .hidden => btn: {
const btn = c.adw_tab_button_new();
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view);
c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
break :btn btn;
},
};
c.gtk_widget_set_focus_on_click(btn, c.FALSE);
self.headerbar.packEnd(btn);
} }
{
const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic");
c.gtk_widget_set_tooltip_text(btn, "New Tab");
_ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(&gtkTabNewClick), self, null, c.G_CONNECT_DEFAULT);
self.headerbar.packStart(btn);
}
_ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(&gtkWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(&gtkWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(&gtkWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT);
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
// need to stick the headerbar into the content box. // need to stick the headerbar into the content box.
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
if (self.header) |h| { c.gtk_box_append(@ptrCast(box), self.headerbar.asWidget());
c.gtk_box_append(@ptrCast(box), h.asWidget());
}
} }
// In debug we show a warning and apply the 'devel' class to the window. // In debug we show a warning and apply the 'devel' class to the window.
@ -273,10 +258,13 @@ pub fn init(self: *Window, app: *App) !void {
} }
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu)));
c.gtk_widget_set_parent(self.context_menu, window); c.gtk_widget_set_parent(self.context_menu, box);
c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0); c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0);
c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START); c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START);
// If we want the window to be maximized, we do that here.
if (app.config.maximize) c.gtk_window_maximize(self.window);
// If we are in fullscreen mode, new windows start fullscreen. // If we are in fullscreen mode, new windows start fullscreen.
if (app.config.fullscreen) c.gtk_window_fullscreen(self.window); if (app.config.fullscreen) c.gtk_window_fullscreen(self.window);
@ -289,6 +277,7 @@ pub fn init(self: *Window, app: *App) !void {
// All of our events // All of our events
_ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(&gtkRefocusTerm), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(&gtkRefocusTerm), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "realize", c.G_CALLBACK(&gtkRealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(&gtkCloseRequest), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(&gtkCloseRequest), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT);
@ -299,10 +288,7 @@ pub fn init(self: *Window, app: *App) !void {
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
if (self.header) |header| { c.adw_toolbar_view_add_top_bar(toolbar_view, self.headerbar.asWidget());
const header_widget = header.asWidget();
c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget);
}
if (self.app.config.@"gtk-tabs-location" != .hidden) { if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new(); const tab_bar = c.adw_tab_bar_new();
@ -375,10 +361,8 @@ pub fn init(self: *Window, app: *App) !void {
box, box,
); );
} else { } else {
c.gtk_window_set_titlebar(gtk_window, self.headerbar.asWidget());
c.gtk_window_set_child(gtk_window, box); c.gtk_window_set_child(gtk_window, box);
if (self.header) |h| {
c.gtk_window_set_titlebar(gtk_window, h.asWidget());
}
} }
} }
@ -386,6 +370,74 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_show(window); c.gtk_widget_show(window);
} }
pub fn updateConfig(
self: *Window,
config: *const configpkg.Config,
) !void {
self.winproto.updateConfigEvent(config) catch |err| {
// We want to continue attempting to make the other config
// changes necessary so we just log the error and continue.
log.warn("failed to update window protocol config error={}", .{err});
};
// We always resync our appearance whenever the config changes.
try self.syncAppearance(config);
}
/// Updates appearance based on config settings. Will be called once upon window
/// realization, and every time the config is reloaded.
///
/// TODO: Many of the initial style settings in `create` could possibly be made
/// reactive by moving them here.
pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void {
self.winproto.syncAppearance() catch |err| {
log.warn("failed to sync winproto appearance error={}", .{err});
};
toggleCssClass(
@ptrCast(self.window),
"background",
config.@"background-opacity" >= 1,
);
// If we are disabling CSDs then disable them right away.
const csd_enabled = self.winproto.clientSideDecorationEnabled();
c.gtk_window_set_decorated(self.window, @intFromBool(csd_enabled));
// If we are not decorated then we hide the titlebar.
self.headerbar.setVisible(config.@"gtk-titlebar" and csd_enabled);
// Disable the title buttons (close, maximize, minimize, ...)
// *inside* the tab overview if CSDs are disabled.
// We do spare the search button, though.
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config))
{
if (self.tab_overview) |tab_overview| {
c.adw_tab_overview_set_show_start_title_buttons(
@ptrCast(tab_overview),
@intFromBool(csd_enabled),
);
c.adw_tab_overview_set_show_end_title_buttons(
@ptrCast(tab_overview),
@intFromBool(csd_enabled),
);
}
}
}
fn toggleCssClass(
widget: *c.GtkWidget,
class: [:0]const u8,
v: bool,
) void {
if (v) {
c.gtk_widget_add_css_class(widget, class);
} else {
c.gtk_widget_remove_css_class(widget, class);
}
}
/// Sets up the GTK actions for the window scope. Actions are how GTK handles /// Sets up the GTK actions for the window scope. Actions are how GTK handles
/// menus and such. The menu is defined in App.zig but the action is defined /// menus and such. The menu is defined in App.zig but the action is defined
/// here. The string name binds them. /// here. The string name binds them.
@ -423,11 +475,23 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void { pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu)); c.gtk_widget_unparent(@ptrCast(self.context_menu));
self.winproto.deinit(self.app.core_app.alloc);
if (self.adw_tab_overview_focus_timer) |timer| { if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer); _ = c.g_source_remove(timer);
} }
} }
/// Set the title of the window.
pub fn setTitle(self: *Window, title: [:0]const u8) void {
self.headerbar.setTitle(title);
}
/// Set the subtitle of the window if it has one.
pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void {
self.headerbar.setSubtitle(subtitle);
}
/// Add a new tab to this window. /// Add a new tab to this window.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
const alloc = self.app.core_app.alloc; const alloc = self.app.core_app.alloc;
@ -473,9 +537,9 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void {
self.notebook.moveTab(tab, position); self.notebook.moveTab(tab, position);
} }
/// Go to the next tab for a surface. /// Go to the last tab for a surface.
pub fn gotoLastTab(self: *Window) void { pub fn gotoLastTab(self: *Window) void {
const max = self.notebook.nPages() -| 1; const max = self.notebook.nPages();
self.gotoTab(@intCast(max)); self.gotoTab(@intCast(max));
} }
@ -498,6 +562,15 @@ pub fn toggleTabOverview(self: *Window) void {
} }
} }
/// Toggle the maximized state for this window.
pub fn toggleMaximize(self: *Window) void {
if (c.gtk_window_is_maximized(self.window) == 0) {
c.gtk_window_maximize(self.window);
} else {
c.gtk_window_unmaximize(self.window);
}
}
/// Toggle fullscreen for this window. /// Toggle fullscreen for this window.
pub fn toggleFullscreen(self: *Window) void { pub fn toggleFullscreen(self: *Window) void {
const is_fullscreen = c.gtk_window_is_fullscreen(self.window); const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
@ -510,24 +583,11 @@ pub fn toggleFullscreen(self: *Window) void {
/// Toggle the window decorations for this window. /// Toggle the window decorations for this window.
pub fn toggleWindowDecorations(self: *Window) void { pub fn toggleWindowDecorations(self: *Window) void {
const old_decorated = c.gtk_window_get_decorated(self.window) == 1; self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") {
const new_decorated = !old_decorated; .auto, .client, .server => .none,
c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); .none => .client,
};
// Fix any artifacting that may occur in window corners. self.updateConfig(&self.app.config) catch {};
if (new_decorated) {
c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
} else {
c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar");
}
// If we have a titlebar, then we also show/hide it depending on the
// decorated state. GTK tends to consider the titlebar part of the frame
// and hides it with decorations, but libadwaita doesn't. This makes it
// explicit.
if (self.header) |headerbar| {
headerbar.setVisible(new_decorated);
}
} }
/// Grabs focus on the currently selected tab. /// Grabs focus on the currently selected tab.
@ -542,7 +602,7 @@ pub fn onConfigReloaded(self: *Window) void {
self.sendToast("Reloaded the configuration"); self.sendToast("Reloaded the configuration");
} }
fn sendToast(self: *Window, title: [:0]const u8) void { pub fn sendToast(self: *Window, title: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) return; if (comptime !adwaita.versionAtLeast(0, 0, 0)) return;
const toast_overlay = self.toast_overlay orelse return; const toast_overlay = self.toast_overlay orelse return;
const toast = c.adw_toast_new(title); const toast = c.adw_toast_new(title);
@ -550,6 +610,85 @@ fn sendToast(self: *Window, title: [:0]const u8) void {
c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast); c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast);
} }
fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
const self = userdataSelf(ud.?);
// Initialize our window protocol logic
if (winproto.Window.init(
self.app.core_app.alloc,
&self.app.winproto,
v,
&self.app.config,
)) |winproto_win| {
self.winproto = winproto_win;
} else |err| {
log.warn("failed to initialize window protocol error={}", .{err});
}
// When we are realized we always setup our appearance
self.syncAppearance(&self.app.config) catch |err| {
log.err("failed to initialize appearance={}", .{err});
};
return true;
}
fn gtkWindowNotifyMaximized(
_: *c.GObject,
_: *c.GParamSpec,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud orelse return);
// Only toggle visibility of the header bar when we're using CSDs,
// and actually intend on displaying the header bar
if (!self.winproto.clientSideDecorationEnabled()) return;
// If we aren't maximized, we should show the headerbar again
// if it was originally visible.
const maximized = c.gtk_window_is_maximized(self.window) != 0;
if (!maximized) {
self.headerbar.setVisible(self.app.config.@"gtk-titlebar");
return;
}
// If we are maximized, we should hide the headerbar if requested.
if (self.app.config.@"gtk-titlebar-hide-when-maximized") {
self.headerbar.setVisible(false);
}
}
fn gtkWindowNotifyDecorated(
object: *c.GObject,
_: *c.GParamSpec,
_: ?*anyopaque,
) callconv(.C) void {
const is_decorated = c.gtk_window_get_decorated(@ptrCast(object)) == 1;
// Fix any artifacting that may occur in window corners. The .ssd CSS
// class is defined in the GtkWindow documentation:
// https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition
// for .ssd is provided by GTK and Adwaita.
toggleCssClass(@ptrCast(object), "ssd", !is_decorated);
toggleCssClass(@ptrCast(object), "no-border-radius", !is_decorated);
}
fn gtkWindowNotifyFullscreened(
object: *c.GObject,
_: *c.GParamSpec,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud orelse return);
const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0;
if (!fullscreened) {
const csd_enabled = self.winproto.clientSideDecorationEnabled();
self.headerbar.setVisible(self.app.config.@"gtk-titlebar" and csd_enabled);
return;
}
self.headerbar.setVisible(false);
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
// sends an undefined value. // sends an undefined value.
fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
@ -894,10 +1033,6 @@ fn gtkActionCopy(
log.warn("error performing binding action error={}", .{err}); log.warn("error performing binding action error={}", .{err});
return; return;
}; };
if (self.app.config.@"adw-toast".@"clipboard-copy") {
self.sendToast("Copied to clipboard");
}
} }
fn gtkActionPaste( fn gtkActionPaste(

View File

@ -11,9 +11,14 @@ pub const c = @cImport({
// Add in X11-specific GDK backend which we use for specific things // Add in X11-specific GDK backend which we use for specific things
// (e.g. X11 window class). // (e.g. X11 window class).
@cInclude("gdk/x11/gdkx.h"); @cInclude("gdk/x11/gdkx.h");
@cInclude("X11/Xlib.h");
@cInclude("X11/Xatom.h");
// Xkb for X11 state handling // Xkb for X11 state handling
@cInclude("X11/XKBlib.h"); @cInclude("X11/XKBlib.h");
} }
if (build_options.wayland) {
@cInclude("gdk/wayland/gdkwayland.h");
}
// generated header files // generated header files
@cInclude("ghostty_resources.h"); @cInclude("ghostty_resources.h");

View File

@ -4,70 +4,55 @@ const c = @import("c.zig").c;
const Window = @import("Window.zig"); const Window = @import("Window.zig");
const adwaita = @import("adwaita.zig"); const adwaita = @import("adwaita.zig");
const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else void; const HeaderBarAdw = @import("headerbar_adw.zig");
const HeaderBarGtk = @import("headerbar_gtk.zig");
pub const HeaderBar = union(enum) { pub const HeaderBar = union(enum) {
adw: *AdwHeaderBar, adw: HeaderBarAdw,
gtk: *c.GtkHeaderBar, gtk: HeaderBarGtk,
pub fn init(window: *Window) HeaderBar { pub fn init(self: *HeaderBar) void {
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and const window: *Window = @fieldParentPtr("headerbar", self);
adwaita.enabled(&window.app.config)) if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) {
{ HeaderBarAdw.init(self);
return initAdw(); } else {
HeaderBarGtk.init(self);
} }
return initGtk();
}
fn initAdw() HeaderBar {
const headerbar = c.adw_header_bar_new();
return .{ .adw = @ptrCast(headerbar) };
}
fn initGtk() HeaderBar {
const headerbar = c.gtk_header_bar_new();
return .{ .gtk = @ptrCast(headerbar) };
} }
pub fn setVisible(self: HeaderBar, visible: bool) void { pub fn setVisible(self: HeaderBar, visible: bool) void {
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); switch (self) {
inline else => |v| v.setVisible(visible),
}
} }
pub fn asWidget(self: HeaderBar) *c.GtkWidget { pub fn asWidget(self: HeaderBar) *c.GtkWidget {
return switch (self) { return switch (self) {
.adw => |headerbar| @ptrCast(@alignCast(headerbar)), inline else => |v| v.asWidget(),
.gtk => |headerbar| @ptrCast(@alignCast(headerbar)),
}; };
} }
pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void { pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void {
switch (self) { switch (self) {
.adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { inline else => |v| v.packEnd(widget),
c.adw_header_bar_pack_end(
@ptrCast(@alignCast(headerbar)),
widget,
);
},
.gtk => |headerbar| c.gtk_header_bar_pack_end(
@ptrCast(@alignCast(headerbar)),
widget,
),
} }
} }
pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void { pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void {
switch (self) { switch (self) {
.adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { inline else => |v| v.packStart(widget),
c.adw_header_bar_pack_start( }
@ptrCast(@alignCast(headerbar)), }
widget,
); pub fn setTitle(self: HeaderBar, title: [:0]const u8) void {
}, switch (self) {
.gtk => |headerbar| c.gtk_header_bar_pack_start( inline else => |v| v.setTitle(title),
@ptrCast(@alignCast(headerbar)), }
widget, }
),
pub fn setSubtitle(self: HeaderBar, subtitle: [:0]const u8) void {
switch (self) {
inline else => |v| v.setSubtitle(subtitle),
} }
} }
}; };

View File

@ -0,0 +1,78 @@
const HeaderBarAdw = @This();
const std = @import("std");
const c = @import("c.zig").c;
const Window = @import("Window.zig");
const adwaita = @import("adwaita.zig");
const HeaderBar = @import("headerbar.zig").HeaderBar;
const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else anyopaque;
const AdwWindowTitle = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwWindowTitle else anyopaque;
/// the window that this headerbar is attached to
window: *Window,
/// the Adwaita headerbar widget
headerbar: *AdwHeaderBar,
/// the Adwaita window title widget
title: *AdwWindowTitle,
pub fn init(headerbar: *HeaderBar) void {
if (!adwaita.versionAtLeast(0, 0, 0)) return;
const window: *Window = @fieldParentPtr("headerbar", headerbar);
headerbar.* = .{
.adw = .{
.window = window,
.headerbar = @ptrCast(@alignCast(c.adw_header_bar_new())),
.title = @ptrCast(@alignCast(c.adw_window_title_new(
c.gtk_window_get_title(window.window) orelse "Ghostty",
null,
))),
},
};
c.adw_header_bar_set_title_widget(
headerbar.adw.headerbar,
@ptrCast(@alignCast(headerbar.adw.title)),
);
}
pub fn setVisible(self: HeaderBarAdw, visible: bool) void {
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible));
}
pub fn asWidget(self: HeaderBarAdw) *c.GtkWidget {
return @ptrCast(@alignCast(self.headerbar));
}
pub fn packEnd(self: HeaderBarAdw, widget: *c.GtkWidget) void {
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_header_bar_pack_end(
@ptrCast(@alignCast(self.headerbar)),
widget,
);
}
}
pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void {
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_header_bar_pack_start(
@ptrCast(@alignCast(self.headerbar)),
widget,
);
}
}
pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void {
c.gtk_window_set_title(self.window.window, title);
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_window_title_set_title(self.title, title);
}
}
pub fn setSubtitle(self: HeaderBarAdw, subtitle: [:0]const u8) void {
if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_window_title_set_subtitle(self.title, subtitle);
}
}

View File

@ -0,0 +1,52 @@
const HeaderBarGtk = @This();
const std = @import("std");
const c = @import("c.zig").c;
const Window = @import("Window.zig");
const adwaita = @import("adwaita.zig");
const HeaderBar = @import("headerbar.zig").HeaderBar;
/// the window that this headarbar is attached to
window: *Window,
/// the GTK headerbar widget
headerbar: *c.GtkHeaderBar,
pub fn init(headerbar: *HeaderBar) void {
const window: *Window = @fieldParentPtr("headerbar", headerbar);
headerbar.* = .{
.gtk = .{
.window = window,
.headerbar = @ptrCast(c.gtk_header_bar_new()),
},
};
}
pub fn setVisible(self: HeaderBarGtk, visible: bool) void {
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible));
}
pub fn asWidget(self: HeaderBarGtk) *c.GtkWidget {
return @ptrCast(@alignCast(self.headerbar));
}
pub fn packEnd(self: HeaderBarGtk, widget: *c.GtkWidget) void {
c.gtk_header_bar_pack_end(
@ptrCast(@alignCast(self.headerbar)),
widget,
);
}
pub fn packStart(self: HeaderBarGtk, widget: *c.GtkWidget) void {
c.gtk_header_bar_pack_start(
@ptrCast(@alignCast(self.headerbar)),
widget,
);
}
pub fn setTitle(self: HeaderBarGtk, title: [:0]const u8) void {
c.gtk_window_set_title(self.window.window, title);
}
pub fn setSubtitle(_: HeaderBarGtk, _: [:0]const u8) void {}

View File

@ -143,6 +143,7 @@ const Window = struct {
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window");
// Initialize our imgui widget // Initialize our imgui widget

View File

@ -2,7 +2,7 @@ const std = @import("std");
const build_options = @import("build_options"); const build_options = @import("build_options");
const input = @import("../../input.zig"); const input = @import("../../input.zig");
const c = @import("c.zig").c; const c = @import("c.zig").c;
const x11 = @import("x11.zig"); const winproto = @import("winproto.zig");
/// Returns a GTK accelerator string from a trigger. /// Returns a GTK accelerator string from a trigger.
pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 {
@ -105,34 +105,14 @@ pub fn keyvalUnicodeUnshifted(
/// This requires a lot of context because the GdkEvent /// This requires a lot of context because the GdkEvent
/// doesn't contain enough on its own. /// doesn't contain enough on its own.
pub fn eventMods( pub fn eventMods(
widget: *c.GtkWidget,
event: *c.GdkEvent, event: *c.GdkEvent,
physical_key: input.Key, physical_key: input.Key,
gtk_mods: c.GdkModifierType, gtk_mods: c.GdkModifierType,
x11_xkb: ?*x11.Xkb, app_winproto: *winproto.App,
) input.Mods { ) input.Mods {
const device = c.gdk_event_get_device(event); const device = c.gdk_event_get_device(event);
var mods = mods: { var mods = app_winproto.eventMods(device, gtk_mods);
// Add any modifier state events from Xkb if we have them (X11
// only). Null back from the Xkb call means there was no modifier
// event to read. This likely means that the key event did not
// result in a modifier change and we can safely rely on the GDK
// state.
if (comptime build_options.x11) {
const display = c.gtk_widget_get_display(widget);
if (x11_xkb) |xkb| {
if (xkb.modifier_state_from_notify(display)) |x11_mods| break :mods x11_mods;
break :mods translateMods(gtk_mods);
}
}
// On Wayland, we have to use the GDK device because the mods sent
// to this event do not have the modifier key applied if it was
// pressed (i.e. left control).
break :mods translateMods(c.gdk_device_get_modifier_state(device));
};
mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1;
switch (physical_key) { switch (physical_key) {

View File

@ -17,6 +17,14 @@ pub const NotebookAdw = struct {
/// the tab view /// the tab view
tab_view: *AdwTabView, tab_view: *AdwTabView,
/// Set to true so that the adw close-page handler knows we're forcing
/// and to allow a close to happen with no confirm. This is a bit of a hack
/// because we currently use GTK alerts to confirm tab close and they
/// don't carry with them the ADW state that we are confirming or not.
/// Long term we should move to ADW alerts so we can know if we are
/// confirming or not.
forcing_close: bool = false,
pub fn init(notebook: *Notebook) void { pub fn init(notebook: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", notebook); const window: *Window = @fieldParentPtr("notebook", notebook);
const app = window.app; const app = window.app;
@ -38,6 +46,7 @@ pub const NotebookAdw = struct {
}; };
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "close-page", c.G_CALLBACK(&adwClosePage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT);
} }
@ -112,11 +121,24 @@ pub const NotebookAdw = struct {
pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { pub fn closeTab(self: *NotebookAdw, tab: *Tab) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
// closeTab always expects to close unconditionally so we mark this
// as true so that the close_page call below doesn't request
// confirmation.
self.forcing_close = true;
const n = self.nPages();
defer {
// self becomes invalid if we close the last page because we close
// the whole window
if (n > 1) self.forcing_close = false;
}
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return;
c.adw_tab_view_close_page(self.tab_view, page); c.adw_tab_view_close_page(self.tab_view, page);
// If we have no more tabs we close the window // If we have no more tabs we close the window
if (self.nPages() == 0) { if (self.nPages() == 0) {
const window = tab.window.window;
// libadw versions <= 1.3.x leak the final page view // libadw versions <= 1.3.x leak the final page view
// which causes our surface to not properly cleanup. We // which causes our surface to not properly cleanup. We
// unref to force the cleanup. This will trigger a critical // unref to force the cleanup. This will trigger a critical
@ -128,7 +150,9 @@ pub const NotebookAdw = struct {
c.g_object_unref(tab.box); c.g_object_unref(tab.box);
} }
c.gtk_window_destroy(tab.window.window); // `self` will become invalid after this call because it will have
// been freed up as part of the process of closing the window.
c.gtk_window_destroy(window);
} }
} }
}; };
@ -143,6 +167,28 @@ fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaqu
window.focusCurrentTab(); window.focusCurrentTab();
} }
fn adwClosePage(
_: *AdwTabView,
page: *c.AdwTabPage,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
const child = c.adw_tab_page_get_child(page);
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(
@ptrCast(child),
Tab.GHOSTTY_TAB,
) orelse return 0));
const window: *Window = @ptrCast(@alignCast(ud.?));
const notebook = window.notebook.adw;
c.adw_tab_view_close_page_finish(
notebook.tab_view,
page,
@intFromBool(notebook.forcing_close),
);
if (!notebook.forcing_close) tab.closeWithConfirmation();
return 1;
}
fn adwTabViewCreateWindow( fn adwTabViewCreateWindow(
_: *AdwTabView, _: *AdwTabView,
ud: ?*anyopaque, ud: ?*anyopaque,
@ -159,5 +205,5 @@ fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
const window: *Window = @ptrCast(@alignCast(ud.?)); const window: *Window = @ptrCast(@alignCast(ud.?));
const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return; const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return;
const title = c.adw_tab_page_get_title(page); const title = c.adw_tab_page_get_title(page);
c.gtk_window_set_title(window.window, title); window.setTitle(std.mem.span(title));
} }

View File

@ -157,8 +157,8 @@ pub const NotebookGtk = struct {
c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0);
c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click));
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
// Tab settings // Tab settings
c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1);
@ -259,7 +259,7 @@ fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaqu
const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page)));
const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box)));
const label_text = c.gtk_label_get_text(gtk_label); const label_text = c.gtk_label_get_text(gtk_label);
c.gtk_window_set_title(window.window, label_text); window.setTitle(std.mem.span(label_text));
} }
fn gtkNotebookCreateWindow( fn gtkNotebookCreateWindow(
@ -283,3 +283,22 @@ fn gtkNotebookCreateWindow(
return newWindow.notebook.gtk.notebook; return newWindow.notebook.gtk.notebook;
} }
fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
const tab: *Tab = @ptrCast(@alignCast(ud));
tab.closeWithConfirmation();
}
fn gtkTabClick(
gesture: *c.GtkGestureClick,
_: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Tab = @ptrCast(@alignCast(ud));
const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture));
if (gtk_button == c.GDK_BUTTON_MIDDLE) {
self.closeWithConfirmation();
}
}

View File

@ -33,7 +33,11 @@ label.size-overlay.hidden {
opacity: 0; opacity: 0;
} }
window.without-window-decoration-and-with-titlebar { window.ssd.no-border-radius {
/* Without clearing the border radius, at least on Mutter with
* gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting
* that this will mitigate.
*/
border-radius: 0 0; border-radius: 0 0;
} }

View File

@ -7,6 +7,11 @@ const c = @import("c.zig").c;
/// in the headers. If it is run in a runtime context, it will /// in the headers. If it is run in a runtime context, it will
/// check the actual version of the library we are linked against. /// check the actual version of the library we are linked against.
/// ///
/// This function should be used in cases where the version check
/// would affect code generation, such as using symbols that are
/// only available beyond a certain version. For checks which only
/// depend on GTK's runtime behavior, use `runtimeAtLeast`.
///
/// This is inlined so that the comptime checks will disable the /// This is inlined so that the comptime checks will disable the
/// runtime checks if the comptime checks fail. /// runtime checks if the comptime checks fail.
pub inline fn atLeast( pub inline fn atLeast(
@ -26,6 +31,20 @@ pub inline fn atLeast(
// If we're in comptime then we can't check the runtime version. // If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true; if (@inComptime()) return true;
return runtimeAtLeast(major, minor, micro);
}
/// Verifies that the GTK version at runtime is at least the given
/// version.
///
/// This function should be used in cases where the only the runtime
/// behavior is affected by the version check. For checks which would
/// affect code generation, use `atLeast`.
pub inline fn runtimeAtLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// We use the functions instead of the constants such as // We use the functions instead of the constants such as
// c.GTK_MINOR_VERSION because the function gets the actual // c.GTK_MINOR_VERSION because the function gets the actual
// runtime version. // runtime version.
@ -44,15 +63,18 @@ test "atLeast" {
const std = @import("std"); const std = @import("std");
const testing = std.testing; const testing = std.testing;
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
}
} }

134
src/apprt/gtk/winproto.zig Normal file
View File

@ -0,0 +1,134 @@
const std = @import("std");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
const Config = @import("../../config.zig").Config;
const input = @import("../../input.zig");
const key = @import("key.zig");
pub const noop = @import("winproto/noop.zig");
pub const x11 = @import("winproto/x11.zig");
pub const wayland = @import("winproto/wayland.zig");
pub const Protocol = enum {
none,
wayland,
x11,
};
/// App-state for the underlying windowing protocol. There should be one
/// instance of this struct per application.
pub const App = union(Protocol) {
none: noop.App,
wayland: if (build_options.wayland) wayland.App else noop.App,
x11: if (build_options.x11) x11.App else noop.App,
pub fn init(
alloc: Allocator,
gdk_display: *c.GdkDisplay,
app_id: [:0]const u8,
config: *const Config,
) !App {
inline for (@typeInfo(App).Union.fields) |field| {
if (try field.type.init(
alloc,
gdk_display,
app_id,
config,
)) |v| {
return @unionInit(App, field.name, v);
}
}
return .{ .none = .{} };
}
pub fn deinit(self: *App, alloc: Allocator) void {
switch (self.*) {
inline else => |*v| v.deinit(alloc),
}
}
pub fn eventMods(
self: *App,
device: ?*c.GdkDevice,
gtk_mods: c.GdkModifierType,
) input.Mods {
return switch (self.*) {
inline else => |*v| v.eventMods(device, gtk_mods),
} orelse key.translateMods(gtk_mods);
}
};
/// Per-Window state for the underlying windowing protocol.
///
/// In Wayland, the terminology used is "Surface" and for it, this is
/// really "Surface"-specific state. But Ghostty uses the term "Surface"
/// heavily to mean something completely different, so we use "Window" here
/// to better match what it generally maps to in the Ghostty codebase.
pub const Window = union(Protocol) {
none: noop.Window,
wayland: if (build_options.wayland) wayland.Window else noop.Window,
x11: if (build_options.x11) x11.Window else noop.Window,
pub fn init(
alloc: Allocator,
app: *App,
window: *c.GtkWindow,
config: *const Config,
) !Window {
return switch (app.*) {
inline else => |*v, tag| {
inline for (@typeInfo(Window).Union.fields) |field| {
if (comptime std.mem.eql(
u8,
field.name,
@tagName(tag),
)) return @unionInit(
Window,
field.name,
try field.type.init(
alloc,
v,
window,
config,
),
);
}
},
};
}
pub fn deinit(self: *Window, alloc: Allocator) void {
switch (self.*) {
inline else => |*v| v.deinit(alloc),
}
}
pub fn resizeEvent(self: *Window) !void {
switch (self.*) {
inline else => |*v| try v.resizeEvent(),
}
}
pub fn updateConfigEvent(
self: *Window,
config: *const Config,
) !void {
switch (self.*) {
inline else => |*v| try v.updateConfigEvent(config),
}
}
pub fn syncAppearance(self: *Window) !void {
switch (self.*) {
inline else => |*v| try v.syncAppearance(),
}
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self) {
inline else => |v| v.clientSideDecorationEnabled(),
};
}
};

View File

@ -0,0 +1,64 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const c = @import("../c.zig").c;
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
const log = std.log.scoped(.winproto_noop);
pub const App = struct {
pub fn init(
_: Allocator,
_: *c.GdkDisplay,
_: [:0]const u8,
_: *const Config,
) !?App {
return null;
}
pub fn deinit(self: *App, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn eventMods(
_: *App,
_: ?*c.GdkDevice,
_: c.GdkModifierType,
) ?input.Mods {
return null;
}
};
pub const Window = struct {
pub fn init(
_: Allocator,
_: *App,
_: *c.GtkWindow,
_: *const Config,
) !Window {
return .{};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn updateConfigEvent(
_: *Window,
_: *const Config,
) !void {}
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(_: *Window) !void {}
/// This returns true if CSD is enabled for this window. This
/// should be the actual present state of the window, not the
/// desired state.
pub fn clientSideDecorationEnabled(self: Window) bool {
_ = self;
return true;
}
};

View File

@ -0,0 +1,302 @@
//! Wayland protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const wayland = @import("wayland");
const Allocator = std.mem.Allocator;
const c = @import("../c.zig").c;
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
const wl = wayland.client.wl;
const org = wayland.client.org;
const log = std.log.scoped(.winproto_wayland);
/// Wayland state that contains application-wide Wayland objects (e.g. wl_display).
pub const App = struct {
display: *wl.Display,
context: *Context,
const Context = struct {
kde_blur_manager: ?*org.KdeKwinBlurManager = null,
// FIXME: replace with `zxdg_decoration_v1` once GTK merges
// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null,
default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null,
};
pub fn init(
alloc: Allocator,
gdk_display: *c.GdkDisplay,
app_id: [:0]const u8,
config: *const Config,
) !?App {
_ = config;
_ = app_id;
// Check if we're actually on Wayland
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(gdk_display)),
c.gdk_wayland_display_get_type(),
) == 0) return null;
const display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(
gdk_display,
) orelse return error.NoWaylandDisplay);
// Create our context for our callbacks so we have a stable pointer.
// Note: at the time of writing this comment, we don't really need
// a stable pointer, but it's too scary that we'd need one in the future
// and not have it and corrupt memory or something so let's just do it.
const context = try alloc.create(Context);
errdefer alloc.destroy(context);
context.* = .{};
// Get our display registry so we can get all the available interfaces
// and bind to what we need.
const registry = try display.getRegistry();
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
if (context.kde_decoration_manager != null) {
// FIXME: Roundtrip again because we have to wait for the decoration
// manager to respond with the preferred default mode. Ew.
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
}
return .{
.display = display,
.context = context,
};
}
pub fn deinit(self: *App, alloc: Allocator) void {
alloc.destroy(self.context);
}
pub fn eventMods(
_: *App,
_: ?*c.GdkDevice,
_: c.GdkModifierType,
) ?input.Mods {
return null;
}
fn registryListener(
registry: *wl.Registry,
event: wl.Registry.Event,
context: *Context,
) void {
switch (event) {
// https://wayland.app/protocols/wayland#wl_registry:event:global
.global => |global| {
log.debug("wl_registry.global: interface={s}", .{global.interface});
if (registryBind(
org.KdeKwinBlurManager,
registry,
global,
)) |blur_manager| {
context.kde_blur_manager = blur_manager;
} else if (registryBind(
org.KdeKwinServerDecorationManager,
registry,
global,
)) |deco_manager| {
context.kde_decoration_manager = deco_manager;
deco_manager.setListener(*Context, decoManagerListener, context);
}
},
// We don't handle removal events
.global_remove => {},
}
}
/// Bind a Wayland interface to a global object. Returns non-null
/// if the binding was successful, otherwise null.
///
/// The type T is the Wayland interface type that we're requesting.
/// This function will verify that the global object is the correct
/// interface and version before binding.
fn registryBind(
comptime T: type,
registry: *wl.Registry,
global: anytype,
) ?*T {
if (std.mem.orderZ(
u8,
global.interface,
T.interface.name,
) != .eq) return null;
return registry.bind(global.name, T, T.generated_version) catch |err| {
log.warn("error binding interface {s} error={}", .{
global.interface,
err,
});
return null;
};
}
fn decoManagerListener(
_: *org.KdeKwinServerDecorationManager,
event: org.KdeKwinServerDecorationManager.Event,
context: *Context,
) void {
switch (event) {
.default_mode => |mode| {
context.default_deco_mode = @enumFromInt(mode.mode);
},
}
}
};
/// Per-window (wl_surface) state for the Wayland protocol.
pub const Window = struct {
config: DerivedConfig,
/// The Wayland surface for this window.
surface: *wl.Surface,
/// The context from the app where we can load our Wayland interfaces.
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
blur_token: ?*org.KdeKwinBlur,
/// Object that controls the decoration mode (client/server/auto)
/// of the window.
decoration: ?*org.KdeKwinServerDecoration,
const DerivedConfig = struct {
blur: bool,
window_decoration: Config.WindowDecoration,
pub fn init(config: *const Config) DerivedConfig {
return .{
.blur = config.@"background-blur".enabled(),
.window_decoration = config.@"window-decoration",
};
}
};
pub fn init(
alloc: Allocator,
app: *App,
gtk_window: *c.GtkWindow,
config: *const Config,
) !Window {
_ = alloc;
const gdk_surface = c.gtk_native_get_surface(
@ptrCast(gtk_window),
) orelse return error.NotWaylandSurface;
// This should never fail, because if we're being called at this point
// then we've already asserted that our app state is Wayland.
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(gdk_surface)),
c.gdk_wayland_surface_get_type(),
) == 0) return error.NotWaylandSurface;
const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(
gdk_surface,
) orelse return error.NoWaylandSurface);
// Get our decoration object so we can control the
// CSD vs SSD status of this surface.
const deco: ?*org.KdeKwinServerDecoration = deco: {
const mgr = app.context.kde_decoration_manager orelse
break :deco null;
const deco: *org.KdeKwinServerDecoration = mgr.create(
wl_surface,
) catch |err| {
log.warn("could not create decoration object={}", .{err});
break :deco null;
};
break :deco deco;
};
return .{
.config = DerivedConfig.init(config),
.surface = wl_surface,
.app_context = app.context,
.blur_token = null,
.decoration = deco,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = alloc;
if (self.blur_token) |blur| blur.release();
if (self.decoration) |deco| deco.release();
}
pub fn updateConfigEvent(
self: *Window,
config: *const Config,
) !void {
self.config = DerivedConfig.init(config);
}
pub fn resizeEvent(_: *Window) !void {}
pub fn syncAppearance(self: *Window) !void {
try self.syncBlur();
try self.syncDecoration();
}
pub fn clientSideDecorationEnabled(self: Window) bool {
// Compositor doesn't support the SSD protocol
if (self.decoration == null) return true;
return switch (self.getDecorationMode()) {
.Client => true,
.Server, .None => false,
else => unreachable,
};
}
/// Update the blur state of the window.
fn syncBlur(self: *Window) !void {
const manager = self.app_context.kde_blur_manager orelse return;
const blur = self.config.blur;
if (self.blur_token) |tok| {
// Only release token when transitioning from blurred -> not blurred
if (!blur) {
manager.unset(self.surface);
tok.release();
self.blur_token = null;
}
} else {
// Only acquire token when transitioning from not blurred -> blurred
if (blur) {
const tok = try manager.create(self.surface);
tok.commit();
self.blur_token = tok;
}
}
}
fn syncDecoration(self: *Window) !void {
const deco = self.decoration orelse return;
// The protocol requests uint instead of enum so we have
// to convert it.
deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode())));
}
fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode {
return switch (self.config.window_decoration) {
.auto => self.app_context.default_deco_mode orelse .Client,
.client => .Client,
.server => .Server,
.none => .None,
};
}
};

View File

@ -0,0 +1,302 @@
//! X11 window protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const c = @import("../c.zig").c;
const input = @import("../../../input.zig");
const Config = @import("../../../config.zig").Config;
const adwaita = @import("../adwaita.zig");
const log = std.log.scoped(.gtk_x11);
pub const App = struct {
display: *c.Display,
base_event_code: c_int,
kde_blur_atom: c.Atom,
pub fn init(
alloc: Allocator,
gdk_display: *c.GdkDisplay,
app_id: [:0]const u8,
config: *const Config,
) !?App {
_ = alloc;
// If the display isn't X11, then we don't need to do anything.
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(gdk_display)),
c.gdk_x11_display_get_type(),
) == 0) return null;
// Get our X11 display
const display: *c.Display = c.gdk_x11_display_get_xdisplay(
gdk_display,
) orelse return error.NoX11Display;
const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn|
pn
else if (builtin.mode == .Debug)
"ghostty-debug"
else
"ghostty";
// Set the X11 window class property (WM_CLASS) if are are on an X11
// display.
//
// Note that we also set the program name here using g_set_prgname.
// This is how the instance name field for WM_CLASS is derived when
// calling gdk_x11_display_set_program_class; there does not seem to be
// a way to set it directly. It does not look like this is being set by
// our other app initialization routines currently, but since we're
// currently deriving its value from x11-instance-name effectively, I
// feel like gating it behind an X11 check is better intent.
//
// This makes the property show up like so when using xprop:
//
// WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty"
//
// Append "-debug" on both when using the debug build.
c.g_set_prgname(x11_program_name);
c.gdk_x11_display_set_program_class(gdk_display, app_id);
// XKB
log.debug("Xkb.init: initializing Xkb", .{});
log.debug("Xkb.init: running XkbQueryExtension", .{});
var opcode: c_int = 0;
var base_event_code: c_int = 0;
var base_error_code: c_int = 0;
var major = c.XkbMajorVersion;
var minor = c.XkbMinorVersion;
if (c.XkbQueryExtension(
display,
&opcode,
&base_event_code,
&base_error_code,
&major,
&minor,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{});
return error.XkbInitializationError;
}
log.debug("Xkb.init: running XkbSelectEventDetails", .{});
if (c.XkbSelectEventDetails(
display,
c.XkbUseCoreKbd,
c.XkbStateNotify,
c.XkbModifierStateMask,
c.XkbModifierStateMask,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{});
return error.XkbInitializationError;
}
return .{
.display = display,
.base_event_code = base_event_code,
.kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(
gdk_display,
"_KDE_NET_WM_BLUR_BEHIND_REGION",
),
};
}
pub fn deinit(self: *App, alloc: Allocator) void {
_ = self;
_ = alloc;
}
/// Checks for an immediate pending XKB state update event, and returns the
/// keyboard state based on if it finds any. This is necessary as the
/// standard GTK X11 API (and X11 in general) does not include the current
/// key pressed in any modifier state snapshot for that event (e.g. if the
/// pressed key is a modifier, that is not necessarily reflected in the
/// modifiers).
///
/// Returns null if there is no event. In this case, the caller should fall
/// back to the standard GDK modifier state (this likely means the key
/// event did not result in a modifier change).
pub fn eventMods(
self: App,
device: ?*c.GdkDevice,
gtk_mods: c.GdkModifierType,
) ?input.Mods {
_ = device;
_ = gtk_mods;
// Shoutout to Mozilla for figuring out a clean way to do this, this is
// paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp.
if (c.XEventsQueued(self.display, c.QueuedAfterReading) == 0) return null;
var nextEvent: c.XEvent = undefined;
_ = c.XPeekEvent(self.display, &nextEvent);
if (nextEvent.type != self.base_event_code) return null;
const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent);
if (xkb_event.any.xkb_type != c.XkbStateNotify) return null;
const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event);
// Check the state according to XKB masks.
const lookup_mods = xkb_state_notify_event.lookup_mods;
var mods: input.Mods = .{};
log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods});
if (lookup_mods & c.ShiftMask != 0) mods.shift = true;
if (lookup_mods & c.ControlMask != 0) mods.ctrl = true;
if (lookup_mods & c.Mod1Mask != 0) mods.alt = true;
if (lookup_mods & c.Mod4Mask != 0) mods.super = true;
if (lookup_mods & c.LockMask != 0) mods.caps_lock = true;
return mods;
}
};
pub const Window = struct {
app: *App,
config: DerivedConfig,
window: c.Window,
gtk_window: *c.GtkWindow,
blur_region: Region,
const DerivedConfig = struct {
blur: bool,
has_decoration: bool,
pub fn init(config: *const Config) DerivedConfig {
return .{
.blur = config.@"background-blur".enabled(),
.has_decoration = switch (config.@"window-decoration") {
.none => false,
.auto, .client, .server => true,
},
};
}
};
pub fn init(
_: Allocator,
app: *App,
gtk_window: *c.GtkWindow,
config: *const Config,
) !Window {
const surface = c.gtk_native_get_surface(
@ptrCast(gtk_window),
) orelse return error.NotX11Surface;
// Check if we're actually on X11
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(surface)),
c.gdk_x11_surface_get_type(),
) == 0) return error.NotX11Surface;
const blur_region: Region = blur: {
if ((comptime !adwaita.versionAtLeast(0, 0, 0)) or
!adwaita.enabled(config)) break :blur .{};
// NOTE(pluiedev): CSDs are a f--king mistake.
// Please, GNOME, stop this nonsense of making a window ~30% bigger
// internally than how they really are just for your shadows and
// rounded corners and all that fluff. Please. I beg of you.
var x: f64 = 0;
var y: f64 = 0;
c.gtk_native_get_surface_transform(
@ptrCast(gtk_window),
&x,
&y,
);
break :blur .{
.x = @intFromFloat(x),
.y = @intFromFloat(y),
};
};
return .{
.app = app,
.config = DerivedConfig.init(config),
.window = c.gdk_x11_surface_get_xid(surface),
.gtk_window = gtk_window,
.blur_region = blur_region,
};
}
pub fn deinit(self: Window, alloc: Allocator) void {
_ = self;
_ = alloc;
}
pub fn updateConfigEvent(
self: *Window,
config: *const Config,
) !void {
self.config = DerivedConfig.init(config);
}
pub fn resizeEvent(self: *Window) !void {
// The blur region must update with window resizes
self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.gtk_window));
self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.gtk_window));
try self.syncBlur();
}
pub fn syncAppearance(self: *Window) !void {
try self.syncBlur();
}
pub fn clientSideDecorationEnabled(self: Window) bool {
return self.config.has_decoration;
}
fn syncBlur(self: *Window) !void {
// FIXME: This doesn't currently factor in rounded corners on Adwaita,
// which means that the blur region will grow slightly outside of the
// window borders. Unfortunately, actually calculating the rounded
// region can be quite complex without having access to existing APIs
// (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134)
// and I think it's not really noticeable enough to justify the effort.
// (Wayland also has this visual artifact anyway...)
const blur = self.config.blur;
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.window,
self.blur_region,
});
if (blur) {
_ = c.XChangeProperty(
self.app.display,
self.window,
self.app.kde_blur_atom,
c.XA_CARDINAL,
// Despite what you might think, the "32" here does NOT mean
// that the data should be in u32s. Instead, they should be
// c_longs, which on any 64-bit architecture would be obviously
// 64 bits. WTF?!
32,
c.PropModeReplace,
// SAFETY: Region is an extern struct that has the same
// representation of 4 c_longs put next to each other.
// Therefore, reinterpretation should be safe.
// We don't have to care about endianness either since
// Xlib converts it to network byte order for us.
@ptrCast(std.mem.asBytes(&self.blur_region)),
4,
);
} else {
_ = c.XDeleteProperty(
self.app.display,
self.window,
self.app.kde_blur_atom,
);
}
}
};
const Region = extern struct {
x: c_long = 0,
y: c_long = 0,
width: c_long = 0,
height: c_long = 0,
};

View File

@ -1,119 +0,0 @@
/// Utility functions for X11 handling.
const std = @import("std");
const build_options = @import("build_options");
const c = @import("c.zig").c;
const input = @import("../../input.zig");
const log = std.log.scoped(.gtk_x11);
/// Returns true if the passed in display is an X11 display.
pub fn is_display(display: ?*c.GdkDisplay) bool {
if (comptime !build_options.x11) return false;
return c.g_type_check_instance_is_a(
@ptrCast(@alignCast(display orelse return false)),
c.gdk_x11_display_get_type(),
) != 0;
}
/// Returns true if the app is running on X11
pub fn is_current_display_server() bool {
if (comptime !build_options.x11) return false;
const display = c.gdk_display_get_default();
return is_display(display);
}
pub const Xkb = struct {
base_event_code: c_int,
/// Initialize an Xkb struct for the given GDK display. If the display isn't
/// backed by X then this will return null.
pub fn init(display_: ?*c.GdkDisplay) !?Xkb {
if (comptime !build_options.x11) return null;
// Display should never be null but we just treat that as a non-X11
// display so that the caller can just ignore it and not unwrap it.
const display = display_ orelse return null;
// If the display isn't X11, then we don't need to do anything.
if (!is_display(display)) return null;
log.debug("Xkb.init: initializing Xkb", .{});
const xdisplay = c.gdk_x11_display_get_xdisplay(display);
var result: Xkb = .{
.base_event_code = 0,
};
log.debug("Xkb.init: running XkbQueryExtension", .{});
var opcode: c_int = 0;
var base_error_code: c_int = 0;
var major = c.XkbMajorVersion;
var minor = c.XkbMinorVersion;
if (c.XkbQueryExtension(
xdisplay,
&opcode,
&result.base_event_code,
&base_error_code,
&major,
&minor,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{});
return error.XkbInitializationError;
}
log.debug("Xkb.init: running XkbSelectEventDetails", .{});
if (c.XkbSelectEventDetails(
xdisplay,
c.XkbUseCoreKbd,
c.XkbStateNotify,
c.XkbModifierStateMask,
c.XkbModifierStateMask,
) == 0) {
log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{});
return error.XkbInitializationError;
}
return result;
}
/// Checks for an immediate pending XKB state update event, and returns the
/// keyboard state based on if it finds any. This is necessary as the
/// standard GTK X11 API (and X11 in general) does not include the current
/// key pressed in any modifier state snapshot for that event (e.g. if the
/// pressed key is a modifier, that is not necessarily reflected in the
/// modifiers).
///
/// Returns null if there is no event. In this case, the caller should fall
/// back to the standard GDK modifier state (this likely means the key
/// event did not result in a modifier change).
pub fn modifier_state_from_notify(self: Xkb, display_: ?*c.GdkDisplay) ?input.Mods {
if (comptime !build_options.x11) return null;
const display = display_ orelse return null;
// Shoutout to Mozilla for figuring out a clean way to do this, this is
// paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp.
const xdisplay = c.gdk_x11_display_get_xdisplay(display);
if (c.XEventsQueued(xdisplay, c.QueuedAfterReading) == 0) return null;
var nextEvent: c.XEvent = undefined;
_ = c.XPeekEvent(xdisplay, &nextEvent);
if (nextEvent.type != self.base_event_code) return null;
const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent);
if (xkb_event.any.xkb_type != c.XkbStateNotify) return null;
const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event);
// Check the state according to XKB masks.
const lookup_mods = xkb_state_notify_event.lookup_mods;
var mods: input.Mods = .{};
log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods});
if (lookup_mods & c.ShiftMask != 0) mods.shift = true;
if (lookup_mods & c.ControlMask != 0) mods.ctrl = true;
if (lookup_mods & c.Mod1Mask != 0) mods.alt = true;
if (lookup_mods & c.Mod4Mask != 0) mods.super = true;
if (lookup_mods & c.LockMask != 0) mods.caps_lock = true;
return mods;
}
};

527
src/build/Config.zig Normal file
View File

@ -0,0 +1,527 @@
/// Build configuration. This is the configuration that is populated
/// during `zig build` to control the rest of the build process.
const Config = @This();
const std = @import("std");
const builtin = @import("builtin");
const apprt = @import("../apprt.zig");
const font = @import("../font/main.zig");
const renderer = @import("../renderer.zig");
const Command = @import("../Command.zig");
const WasmTarget = @import("../os/wasm/target.zig").Target;
const gtk = @import("gtk.zig");
const GitVersion = @import("GitVersion.zig");
/// The version of the next release.
///
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release.
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 0, .patch = 2 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,
target: std.Build.ResolvedTarget,
wasm_target: WasmTarget,
/// Comptime interfaces
app_runtime: apprt.Runtime = .none,
renderer: renderer.Impl = .opengl,
font_backend: font.Backend = .freetype,
/// Feature flags
adwaita: bool = false,
x11: bool = false,
wayland: bool = false,
sentry: bool = true,
wasm_shared: bool = true,
/// Ghostty exe properties
exe_entrypoint: ExeEntrypoint = .ghostty,
version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 },
/// Binary properties
pie: bool = false,
strip: bool = false,
patch_rpath: ?[]const u8 = null,
/// Artifacts
flatpak: bool = false,
emit_test_exe: bool = false,
emit_bench: bool = false,
emit_helpgen: bool = false,
emit_docs: bool = false,
emit_webdata: bool = false,
emit_xcframework: bool = false,
emit_terminfo: bool = false,
emit_termcap: bool = false,
/// Environmental properties
env: std.process.EnvMap,
pub fn init(b: *std.Build) !Config {
// Setup our standard Zig target and optimize options, i.e.
// `-Doptimize` and `-Dtarget`.
const optimize = b.standardOptimizeOption(.{});
const target = target: {
var result = b.standardTargetOptions(.{});
// If we're building for macOS and we're on macOS, we need to
// use a generic target to workaround compilation issues.
if (result.result.os.tag == .macos and builtin.target.isDarwin()) {
result = genericMacOSTarget(b, null);
}
// If we have no minimum OS version, we set the default based on
// our tag. Not all tags have a minimum so this may be null.
if (result.query.os_version_min == null) {
result.query.os_version_min = osVersionMin(result.result.os.tag);
}
break :target result;
};
// This is set to true when we're building a system package. For now
// this is trivially detected using the "system_package_mode" bool
// but we may want to make this more sophisticated in the future.
const system_package: bool = b.graph.system_package_mode;
// This specifies our target wasm runtime. For now only one semi-usable
// one exists so this is hardcoded.
const wasm_target: WasmTarget = .browser;
// Determine whether GTK supports X11 and Wayland. This is always safe
// to run even on non-Linux platforms because any failures result in
// defaults.
const gtk_targets = gtk.targets(b);
// We use env vars throughout the build so we grab them immediately here.
var env = try std.process.getEnvMap(b.allocator);
errdefer env.deinit();
var config: Config = .{
.optimize = optimize,
.target = target,
.wasm_target = wasm_target,
.env = env,
};
//---------------------------------------------------------------
// Comptime Interfaces
config.font_backend = b.option(
font.Backend,
"font-backend",
"The font backend to use for discovery and rasterization.",
) orelse font.Backend.default(target.result, wasm_target);
config.app_runtime = b.option(
apprt.Runtime,
"app-runtime",
"The app runtime to use. Not all values supported on all platforms.",
) orelse apprt.Runtime.default(target.result);
config.renderer = b.option(
renderer.Impl,
"renderer",
"The app runtime to use. Not all values supported on all platforms.",
) orelse renderer.Impl.default(target.result, wasm_target);
//---------------------------------------------------------------
// Feature Flags
config.adwaita = b.option(
bool,
"gtk-adwaita",
"Enables the use of Adwaita when using the GTK rendering backend.",
) orelse true;
config.flatpak = b.option(
bool,
"flatpak",
"Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.",
) orelse false;
config.sentry = b.option(
bool,
"sentry",
"Build with Sentry crash reporting. Default for macOS is true, false for any other system.",
) orelse sentry: {
switch (target.result.os.tag) {
.macos, .ios => break :sentry true,
// Note its false for linux because the crash reports on Linux
// don't have much useful information.
else => break :sentry false,
}
};
config.wayland = b.option(
bool,
"gtk-wayland",
"Enables linking against Wayland libraries when using the GTK rendering backend.",
) orelse gtk_targets.wayland;
config.x11 = b.option(
bool,
"gtk-x11",
"Enables linking against X11 libraries when using the GTK rendering backend.",
) orelse gtk_targets.x11;
//---------------------------------------------------------------
// Ghostty Exe Properties
const version_string = b.option(
[]const u8,
"version-string",
"A specific version string to use for the build. " ++
"If not specified, git will be used. This must be a semantic version.",
);
config.version = if (version_string) |v|
// If an explicit version is given, we always use it.
try std.SemanticVersion.parse(v)
else version: {
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {
// If Git isn't available we just make an unknown dev version.
error.GitNotFound,
error.GitNotRepository,
=> break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
.pre = "dev",
.build = "0000000",
},
else => return err,
};
if (vsn.tag) |tag| {
// Tip releases behave just like any other pre-release so we skip.
if (!std.mem.eql(u8, tag, "tip")) {
const expected = b.fmt("v{d}.{d}.{d}", .{
app_version.major,
app_version.minor,
app_version.patch,
});
if (!std.mem.eql(u8, tag, expected)) {
@panic("tagged releases must be in vX.Y.Z format matching build.zig");
}
break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
}
}
break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
.pre = vsn.branch,
.build = vsn.short_hash,
};
};
//---------------------------------------------------------------
// Binary Properties
// On NixOS, the built binary from `zig build` needs to patch the rpath
// into the built binary for it to be portable across the NixOS system
// it was built for. We default this to true if we can detect we're in
// a Nix shell and have LD_LIBRARY_PATH set.
config.patch_rpath = b.option(
[]const u8,
"patch-rpath",
"Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++
"This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.",
) orelse patch_rpath: {
// We only do the patching if we're targeting our own CPU and its Linux.
if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null;
// If we're in a nix shell we default to doing this.
// Note: we purposely never deinit envmap because we leak the strings
if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null;
break :patch_rpath env.get("LD_LIBRARY_PATH");
};
config.pie = b.option(
bool,
"pie",
"Build a Position Independent Executable. Default true for system packages.",
) orelse system_package;
config.strip = b.option(
bool,
"strip",
"Strip the final executable. Default true for fast and small releases",
) orelse switch (optimize) {
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
};
//---------------------------------------------------------------
// Artifacts to Emit
config.emit_test_exe = b.option(
bool,
"emit-test-exe",
"Build and install test executables with 'build'",
) orelse false;
config.emit_bench = b.option(
bool,
"emit-bench",
"Build and install the benchmark executables.",
) orelse false;
config.emit_helpgen = b.option(
bool,
"emit-helpgen",
"Build and install the helpgen executable.",
) orelse false;
config.emit_docs = b.option(
bool,
"emit-docs",
"Build and install auto-generated documentation (requires pandoc)",
) orelse emit_docs: {
// If we are emitting any other artifacts then we default to false.
if (config.emit_bench or
config.emit_test_exe or
config.emit_helpgen) break :emit_docs false;
// We always emit docs in system package mode.
if (system_package) break :emit_docs true;
// We only default to true if we can find pandoc.
const path = Command.expandPath(b.allocator, "pandoc") catch
break :emit_docs false;
defer if (path) |p| b.allocator.free(p);
break :emit_docs path != null;
};
config.emit_terminfo = b.option(
bool,
"emit-terminfo",
"Install Ghostty terminfo source file",
) orelse switch (target.result.os.tag) {
.windows => true,
else => switch (optimize) {
.Debug => true,
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
},
};
config.emit_termcap = b.option(
bool,
"emit-termcap",
"Install Ghostty termcap file",
) orelse switch (optimize) {
.Debug => true,
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
};
config.emit_webdata = b.option(
bool,
"emit-webdata",
"Build the website data for the website.",
) orelse false;
config.emit_xcframework = b.option(
bool,
"emit-xcframework",
"Build and install the xcframework for the macOS library.",
) orelse builtin.target.isDarwin() and
target.result.os.tag == .macos and
config.app_runtime == .none and
(!config.emit_bench and
!config.emit_test_exe and
!config.emit_helpgen);
//---------------------------------------------------------------
// System Packages
// These are all our dependencies that can be used with system
// packages if they exist. We set them up here so that we can set
// their defaults early. The first call configures the integration and
// subsequent calls just return the configured value. This lets them
// show up properly in `--help`.
{
// These dependencies we want to default false if we're on macOS.
// On macOS we don't want to use system libraries because we
// generally want a fat binary. This can be overridden with the
// `-fsys` flag.
for (&[_][]const u8{
"freetype",
"harfbuzz",
"fontconfig",
"libpng",
"zlib",
"oniguruma",
}) |dep| {
_ = b.systemIntegrationOption(
dep,
.{
// If we're not on darwin we want to use whatever the
// default is via the system package mode
.default = if (target.result.isDarwin()) false else null,
},
);
}
// These default to false because they're rarely available as
// system packages so we usually want to statically link them.
for (&[_][]const u8{
"glslang",
"spirv-cross",
"simdutf",
}) |dep| {
_ = b.systemIntegrationOption(dep, .{ .default = false });
}
}
return config;
}
/// Configure the build options with our values.
pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
// We need to break these down individual because addOption doesn't
// support all types.
step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "adwaita", self.adwaita);
step.addOption(bool, "x11", self.x11);
step.addOption(bool, "wayland", self.wayland);
step.addOption(bool, "sentry", self.sentry);
step.addOption(apprt.Runtime, "app_runtime", self.app_runtime);
step.addOption(font.Backend, "font_backend", self.font_backend);
step.addOption(renderer.Impl, "renderer", self.renderer);
step.addOption(ExeEntrypoint, "exe_entrypoint", self.exe_entrypoint);
step.addOption(WasmTarget, "wasm_target", self.wasm_target);
step.addOption(bool, "wasm_shared", self.wasm_shared);
// Our version. We also add the string version so we don't need
// to do any allocations at runtime. This has to be long enough to
// accommodate realistic large branch names for dev versions.
var buf: [1024]u8 = undefined;
step.addOption(std.SemanticVersion, "app_version", self.version);
step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ(
&buf,
"{}",
.{self.version},
));
step.addOption(
ReleaseChannel,
"release_channel",
channel: {
const pre = self.version.pre orelse break :channel .stable;
if (pre.len == 0) break :channel .stable;
break :channel .tip;
},
);
}
/// Rehydrate our Config from the comptime options. Note that not all
/// options are available at comptime, so look closely at this implementation
/// to see what is and isn't available.
pub fn fromOptions() Config {
const options = @import("build_options");
return .{
// Unused at runtime.
.optimize = undefined,
.target = undefined,
.env = undefined,
.version = options.app_version,
.flatpak = options.flatpak,
.adwaita = options.adwaita,
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
.renderer = std.meta.stringToEnum(renderer.Impl, @tagName(options.renderer)).?,
.exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?,
.wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?,
.wasm_shared = options.wasm_shared,
};
}
/// Returns the minimum OS version for the given OS tag. This shouldn't
/// be used generally, it should only be used for Darwin-based OS currently.
pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion {
return switch (tag) {
// We support back to the earliest officially supported version
// of macOS by Apple. EOL versions are not supported.
.macos => .{ .semver = .{
.major = 13,
.minor = 0,
.patch = 0,
} },
// iOS 17 picked arbitrarily
.ios => .{ .semver = .{
.major = 17,
.minor = 0,
.patch = 0,
} },
// This should never happen currently. If we add a new target then
// we should add a new case here.
else => null,
};
}
// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`.
// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`.
//
// This is used to workaround compilation issues on macOS.
// (see for example https://github.com/mitchellh/ghostty/issues/1640).
pub fn genericMacOSTarget(
b: *std.Build,
arch: ?std.Target.Cpu.Arch,
) std.Build.ResolvedTarget {
return b.resolveTargetQuery(.{
.cpu_arch = arch orelse builtin.target.cpu.arch,
.os_tag = .macos,
.os_version_min = osVersionMin(.macos),
});
}
/// The possible entrypoints for the exe artifact. This has no effect on
/// other artifact types (i.e. lib, wasm_module).
///
/// The whole existence of this enum is to workaround the fact that Zig
/// doesn't allow the main function to be in a file in a subdirctory
/// from the "root" of the module, and I don't want to pollute our root
/// directory with a bunch of individual zig files for each entrypoint.
///
/// Therefore, main.zig uses this to switch between the different entrypoints.
pub const ExeEntrypoint = enum {
ghostty,
helpgen,
mdgen_ghostty_1,
mdgen_ghostty_5,
webgen_config,
webgen_actions,
webgen_commands,
bench_parser,
bench_stream,
bench_codepoint_width,
bench_grapheme_break,
bench_page_init,
};
/// The release channel for the build.
pub const ReleaseChannel = enum {
/// Unstable builds on every commit.
tip,
/// Stable tagged releases.
stable,
};

View File

@ -0,0 +1,69 @@
//! GhosttyBench generates all the Ghostty benchmark helper binaries.
const GhosttyBench = @This();
const std = @import("std");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
steps: []*std.Build.Step.Compile,
pub fn init(
b: *std.Build,
deps: *const SharedDeps,
) !GhosttyBench {
var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator);
errdefer steps.deinit();
// Open the directory ./src/bench
const c_dir_path = b.pathFromRoot("src/bench");
var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true });
defer c_dir.close();
// Go through and add each as a step
var c_dir_it = c_dir.iterate();
while (try c_dir_it.next()) |entry| {
// Get the index of the last '.' so we can strip the extension.
const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue;
if (index == 0) continue;
// If it doesn't end in 'zig' then ignore
if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue;
// Name of the conformance app and full path to the entrypoint.
const name = entry.name[0..index];
// Executable builder.
const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name});
const c_exe = b.addExecutable(.{
.name = bin_name,
.root_source_file = b.path("src/main.zig"),
.target = deps.config.target,
// We always want our benchmarks to be in release mode.
.optimize = .ReleaseFast,
});
c_exe.linkLibC();
// Update our entrypoint
var enum_name: [64]u8 = undefined;
@memcpy(enum_name[0..name.len], name);
std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_');
var buf: [64]u8 = undefined;
const new_deps = try deps.changeEntrypoint(b, std.meta.stringToEnum(
Config.ExeEntrypoint,
try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}),
).?);
_ = try new_deps.add(c_exe);
try steps.append(c_exe);
}
return .{ .steps = steps.items };
}
pub fn install(self: *const GhosttyBench) void {
const b = self.steps[0].step.owner;
for (self.steps) |step| b.installArtifact(step);
}

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