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]
name: Nix
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:
if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-sm

View File

@ -6,6 +6,45 @@ on:
name: Test
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:
strategy:
fail-fast: false
@ -247,10 +286,10 @@ jobs:
run: |
# Get the zig version from build.zig so that it only needs to be updated
$fileContent = Get-Content -Path "build.zig" -Raw
$pattern = 'const required_zig = "(.*?)";'
$pattern = 'buildpkg\.requireZig\("(.*?)"\);'
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
Write-Output $version
$version = "zig-windows-x86_64-$zigVersion"
Write-Output $version
$uri = "https://ziglang.org/download/$zigVersion/$version.zip"
Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip"
Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force
@ -342,7 +381,8 @@ jobs:
matrix:
adwaita: ["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
needs: test
env:
@ -374,7 +414,8 @@ jobs:
zig build \
-Dapp-runtime=gtk \
-Dgtk-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }}
-Dgtk-x11=${{ matrix.x11 }} \
-Dgtk-wayland=${{ matrix.wayland }}
test-sentry-linux:
strategy:

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ test/cases/**/*.actual.png
glad.zip
/Box_test.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
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> 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 = .{
// Zig libs
.libxev = .{
.url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz",
.hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf",
.url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz",
.hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c",
},
.mach_glfw = .{
.url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz",
.hash = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62",
.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 = .{
.url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz",
.hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634",
@ -25,6 +33,14 @@
.url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz",
.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
.cimgui = .{ .path = "./pkg/cimgui" },
@ -46,23 +62,25 @@
.glslang = .{ .path = "./pkg/glslang" },
.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
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz",
.hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620",
},
.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",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/0e23daf59234fc892cba949562d7bf69204594bb.tar.gz",
.hash = "12204fc99743d8232e691ac22e058519bfc6ea92de4a11c6dba59b117531c847cd6a",
},
},
}

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;
Keywords=terminal;tty;pty;
StartupNotify=true
StartupWMClass=com.mitchellh.ghostty
Terminal=false
Actions=new-window;
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,
...
}:
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let
pkgs-stable = nixpkgs-stable.legacyPackages.${system};
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
in {
devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
zig = zig.packages.${system}."0.13.0";
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
};
builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
builtins.map (
system: let
pkgs-stable = nixpkgs-stable.legacyPackages.${system};
pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
in {
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
mkArgs = optimize: {
inherit optimize;
packages.${system} = let
mkArgs = optimize: {
inherit optimize;
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
};
ghostty = ghostty-releasefast;
default = ghostty;
};
formatter.${system} = pkgs-stable.alejandra;
formatter.${system} = pkgs-stable.alejandra;
# Our supported systems are the same supported systems as the Zig binaries.
}) (builtins.attrNames zig.packages))
apps.${system} = let
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: {
ghostty = self.packages.${prev.system}.default;
overlays = {
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 = {

View File

@ -159,7 +159,7 @@ typedef enum {
GHOSTTY_KEY_EQUAL,
GHOSTTY_KEY_LEFT_BRACKET, // [
GHOSTTY_KEY_RIGHT_BRACKET, // ]
GHOSTTY_KEY_BACKSLASH, // /
GHOSTTY_KEY_BACKSLASH, // \
// control
GHOSTTY_KEY_UP,
@ -559,10 +559,13 @@ typedef struct {
// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
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_config_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_set_focus(ghostty_app_t, bool);
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_open_config(ghostty_app_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_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
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);
bool ghostty_surface_mouse_captured(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 */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
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 */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
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 */; };
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 */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
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 */; };
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 */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.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 */; };
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.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 */; };
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; };
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 */; };
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
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 */; };
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
/* End PBXBuildFile section */
@ -108,8 +115,8 @@
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
@ -158,10 +165,14 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -177,6 +188,8 @@
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>"; };
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>"; };
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; };
@ -192,6 +205,7 @@
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>"; };
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>"; };
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -261,18 +275,22 @@
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
A5A2A3C92D4445E20033CF96 /* Dock.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
A5CEAFDA29B8005900646FDA /* SplitView */,
@ -351,12 +369,14 @@
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
);
path = Ghostty;
sourceTree = "<group>";
@ -399,13 +419,13 @@
children = (
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */,
29C15B1C2CDC3B2000520DD4 /* bat */,
55154BDF2B33911F001622DC /* ghostty */,
552964E52B34A9B400030505 /* vim */,
A586167B2B7703CC009BDB1D /* fish */,
55154BDF2B33911F001622DC /* ghostty */,
A5985CE52C33060F00C57AD3 /* man */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
FC5218F92D10FFC7004C93E0 /* zsh */,
9351BE8E2D22937F003B3499 /* nvim */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
552964E52B34A9B400030505 /* vim */,
FC5218F92D10FFC7004C93E0 /* zsh */,
);
name = Resources;
sourceTree = "<group>";
@ -439,6 +459,7 @@
children = (
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
@ -607,16 +628,20 @@
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@ -632,12 +657,14 @@
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
@ -647,6 +674,7 @@
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
@ -765,21 +793,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
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_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@ -934,21 +963,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
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_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@ -987,21 +1017,22 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth.";
INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar.";
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_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network.";
INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily.";
INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone.";
INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library.";
INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders.";
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition.";
INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",

View File

@ -30,11 +30,13 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSplitRight: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseTab: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuToggleVisibility: NSMenuItem?
@ -90,10 +92,8 @@ class AppDelegate: NSObject,
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
}
/// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually
/// brings each window one by one to the front. But at worst its off by one set of toggles and this
/// makes our logic very easy.
private var isVisible: Bool = true
/// Tracks the windows that we hid for toggleVisibility.
private var hiddenWindows: [Weak<NSWindow>] = []
/// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil
@ -217,15 +217,20 @@ class AppDelegate: NSObject,
}
func applicationDidBecomeActive(_ notification: Notification) {
guard !applicationHasBecomeActive else { return }
applicationHasBecomeActive = true
// If we're back then clear the hidden windows
self.hiddenWindows = []
// Let's launch our first window. We only do this if we have no other windows. It
// 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()
// First launch stuff
if (!applicationHasBecomeActive) {
applicationHasBecomeActive = true
// Let's launch our first window. We only do this if we have no other windows. It
// 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_tab", menuItem: self.menuNewTab)
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_all_windows", menuItem: self.menuCloseAllWindows)
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: "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: "toggle_split_zoom", menuItem: self.menuZoomSplit)
@ -424,32 +431,42 @@ class AppDelegate: NSObject,
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
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 let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) {
return nil
}
// If we reach this point then we try to process the key event
// through the Ghostty key mechanism.
// Ghostty must be loaded
guard let ghostty = self.ghostty.app else { return event }
// Build our event input and call ghostty
var key_ev = ghostty_input_key_s()
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)) {
if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
// 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)")
return nil
}
return event
}
@ -692,21 +709,23 @@ class AppDelegate: NSObject,
/// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application
@IBAction func toggleVisibility(_ sender: Any) {
// We only care about terminal windows.
for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) {
if isVisible {
window.orderOut(nil)
} else {
window.makeKeyAndOrderFront(nil)
}
// If we have focus, then we hide all windows.
if NSApp.isActive {
// We need to keep track of the windows that were visible because we only
// want to bring back these windows if we remove the toggle.
self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) }
NSApp.hide(nil)
return
}
// After bringing them all to front we make sure our app is active too.
if !isVisible {
NSApp.activate(ignoringOtherApps: true)
}
// If we're not active, we want to become active
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 {

View File

@ -1,8 +1,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>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
</dependencies>
<objects>
<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="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
<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="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<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="menuOpenConfig" destination="BOF-NM-1cW" id="Nze-Go-glw"/>
<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="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
@ -154,6 +156,12 @@
<action selector="close:" target="-1" id="tTZ-2b-Mbm"/>
</connections>
</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">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
@ -185,6 +193,12 @@
<action selector="paste:" target="-1" id="ZKe-2B-mel"/>
</connections>
</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">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

@ -3,6 +3,12 @@ import Cocoa
import SwiftUI
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.
class QuickTerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "QuickTerminal" }
@ -18,6 +24,13 @@ class QuickTerminalController: BaseTerminalController {
/// application to the front.
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.
private var derivedConfig: DerivedConfig
@ -107,8 +120,28 @@ class QuickTerminalController: BaseTerminalController {
self.previousApp = nil
}
if (derivedConfig.quickTerminalAutoHide) {
animateOut()
if derivedConfig.quickTerminalAutoHide {
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
animateWindowIn(window: window, from: position)
@ -192,14 +228,39 @@ class QuickTerminalController: BaseTerminalController {
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) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Move our window off screen to the top
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
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
// it visible.
@ -211,8 +272,16 @@ class QuickTerminalController: BaseTerminalController {
// 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.
DispatchQueue.main.async {
// If we canceled our animation in we do nothing
guard self.visible else { return }
// If we canceled our animation clean up some state.
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
// requires the window is visible.
@ -276,6 +345,17 @@ class QuickTerminalController: BaseTerminalController {
}
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.
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
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
@ -311,23 +396,13 @@ class QuickTerminalController: BaseTerminalController {
private func syncAppearance() {
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.
// Some APIs such as window blur have no effect unless the window is visible.
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 (self.derivedConfig.backgroundOpacity < 1) {
window.isOpaque = false
@ -396,14 +471,14 @@ class QuickTerminalController: BaseTerminalController {
let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool
let windowColorspace: String
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let backgroundOpacity: Double
init() {
self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true
self.windowColorspace = ""
self.quickTerminalSpaceBehavior = .move
self.backgroundOpacity = 1.0
}
@ -411,7 +486,7 @@ class QuickTerminalController: BaseTerminalController {
self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.windowColorspace = config.windowColorspace
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.backgroundOpacity = config.backgroundOpacity
}
}

View File

@ -69,7 +69,7 @@ enum QuickTerminalPosition : String {
finalSize.width = screen.frame.width
case .left, .right:
finalSize.height = screen.frame.height
finalSize.height = screen.visibleFrame.height
case .center:
finalSize.width = screen.frame.width / 2
@ -89,13 +89,13 @@ enum QuickTerminalPosition : String {
return .init(x: screen.frame.minX, y: -window.frame.height)
case .left:
return .init(x: -window.frame.width, y: 0)
return .init(x: screen.frame.minX-window.frame.width, y: 0)
case .right:
return .init(x: screen.frame.maxX, y: 0)
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)
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
class QuickTerminalWindow: NSWindow {
class QuickTerminalWindow: NSPanel {
// Both of these must be true for windows without decorations to be able to
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
@ -26,22 +26,7 @@ class QuickTerminalWindow: NSWindow {
// window remains resizable.
self.styleMask.remove(.titled)
// 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.
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]
// We don't want to activate the owning app when quick terminal is triggered.
self.styleMask.insert(.nonactivatingPanel)
}
}

View File

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

View File

@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController {
private var restorable: Bool = true
/// 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.
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
@ -60,6 +60,11 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTab),
name: .ghosttyCloseTab,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
@ -310,28 +315,28 @@ class TerminalController: BaseTerminalController {
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
// Full size content view so we can extend
// content in to the hidden titlebar's area
.fullSizeContentView,
.resizable,
.fullSizeContentView,
.resizable,
.closable,
.miniaturizable,
]
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Hide the traffic lights (window control buttons)
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
// some operations that appear to bring back the titlebar visibility so this ensures
// it is gone forever.
@ -340,7 +345,7 @@ class TerminalController: BaseTerminalController {
titleBarContainer.isHidden = true
}
}
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow else { return }
@ -361,33 +366,31 @@ class TerminalController: BaseTerminalController {
// If window decorations are disabled, remove our title
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
// an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree {
if let initialSize = leaf.surface.initialSize,
let screen = window.screen ?? NSScreen.main {
// Setup our frame. We need to first subtract the views frame so that we can
// just get the chrome frame so that we only affect the surface view size.
// Get the current frame of the window
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)
}
}
@ -508,7 +511,50 @@ class TerminalController: BaseTerminalController {
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 tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close.
@ -523,47 +569,34 @@ class TerminalController: BaseTerminalController {
}
// Check if any windows require close confirmation.
var needsConfirm: Bool = false
for tabWindow in tabGroup.windows {
guard let c = tabWindow.windowController as? TerminalController else { continue }
if (c.surfaceTree?.needsConfirmQuit() ?? false) {
needsConfirm = true
break
let needsConfirm = tabGroup.windows.contains { tabWindow in
guard let controller = tabWindow.windowController as? TerminalController else {
return false
}
return controller.surfaceTree?.needsConfirmQuit() ?? false
}
// If none need confirmation then we can just close all the windows.
if (!needsConfirm) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
if !needsConfirm {
tabGroup.windows.forEach { $0.close() }
return
}
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = "Close Window?"
alert.informativeText = "All terminal sessions in this window will be terminated."
alert.addButton(withTitle: "Close Window")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
if (response == .alertFirstButtonReturn) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
}
})
confirmClose(
window: window,
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated."
) {
tabGroup.windows.forEach { $0.close() }
}
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
@IBAction func toggleGhosttyFullScreen(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
@IBAction func toggleTerminalInspector(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
@ -720,6 +753,12 @@ class TerminalController: BaseTerminalController {
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) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
@ -737,7 +776,7 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode)
}
private struct DerivedConfig {
struct DerivedConfig {
let backgroundColor: Color
let macosTitlebarStyle: String

View File

@ -10,7 +10,7 @@ protocol TerminalViewDelegate: AnyObject {
/// The title of the terminal should change.
func titleDidChange(to: String)
/// The URL of the pwd should change.
func pwdDidChange(to: URL?)
@ -56,15 +56,10 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// The title for our window
private var title: String {
var title = "👻"
if let surfaceTitle = surfaceTitle {
if (surfaceTitle.count > 0) {
title = surfaceTitle
}
if let surfaceTitle, !surfaceTitle.isEmpty {
return surfaceTitle
}
return title
return "👻"
}
// The pwd of the focused surface as a URL
@ -72,7 +67,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
guard let surfacePwd, surfacePwd != "" else { return nil }
return URL(fileURLWithPath: surfacePwd)
}
var body: some View {
switch ghostty.readiness {
case .loading:

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
// behaviors that don't look quite right in certain situations.
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.
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 overlayLayer = VibrantLayer()
var isHighlighted: Bool = true {
didSet {
guard let terminalWindow else { return }
if isLightTheme {
overlayLayer.isHidden = isHighlighted
layer?.backgroundColor = .clear

View File

@ -62,7 +62,7 @@ extension Ghostty {
// uses to interface with the application runtime environment.
var runtime_cfg = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(),
supports_selection_clipboard: false,
supports_selection_clipboard: true,
wakeup_cb: { userdata in App.wakeup(userdata) },
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) },
@ -117,23 +117,7 @@ extension Ghostty {
func appTick() {
guard let app = self.app else { return }
// 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
ghostty_app_tick(app)
}
func openConfig() {
@ -336,13 +320,13 @@ extension Ghostty {
let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) {
// Get our pasteboard
guard let pasteboard = NSPasteboard.ghostty(location) else {
return completeClipboardRequest(surface, data: "", state: state)
}
// Get our string
let str = NSPasteboard.general.getOpinionatedStringContents() ?? ""
let str = pasteboard.getOpinionatedStringContents() ?? ""
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) {
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 }
if !confirm {
let pb = NSPasteboard.general
pb.declareTypes([.string], owner: nil)
pb.setString(valueStr, forType: .string)
pasteboard.declareTypes([.string], owner: nil)
pasteboard.setString(valueStr, forType: .string)
return
}
@ -396,7 +378,7 @@ extension Ghostty {
object: surface,
userInfo: [
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
switch (action.tag) {
case GHOSTTY_ACTION_QUIT:
quit(app)
case GHOSTTY_ACTION_NEW_WINDOW:
newWindow(app, target: target)
@ -463,6 +448,9 @@ extension Ghostty {
case GHOSTTY_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:
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) {
switch (target.tag) {
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(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@ -132,15 +132,6 @@ extension Ghostty {
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 {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -174,11 +165,14 @@ extension Ghostty {
}
var windowDecorations: Bool {
guard let config = self.config else { return true }
var v = false;
let defaultValue = true
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil
let key = "window-decoration"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue }
let str = String(cString: ptr)
return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue
}
var windowTheme: String? {
@ -345,7 +339,7 @@ extension Ghostty {
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
let key = "background-blur-radius"
let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
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 {
let backgroundColor = OSColor(backgroundColor)
let isLightBackground = backgroundColor.isLightColor
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)
@ -420,6 +425,16 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
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
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
switch (response) {
case .alertFirstButtonReturn:
alert.window.orderOut(nil)
node = nil
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
/// 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
func text() -> String {
@ -188,7 +188,7 @@ extension Ghostty {
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write
return .osc_52_write(nil)
default:
return nil
}
@ -236,6 +236,9 @@ extension Notification.Name {
/// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
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

View File

@ -92,22 +92,6 @@ extension Ghostty {
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
// If our geo size changed then we show the resize overlay as configured.

View File

@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import CoreText
import UserNotifications
@ -12,7 +13,14 @@ extension Ghostty {
// 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
// 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
// changed with escape codes.
@ -113,6 +121,12 @@ extension Ghostty {
// A small delay that is introduced before a title change to avoid flickers
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
override var acceptsFirstResponder: Bool { return true }
@ -136,6 +150,13 @@ extension Ghostty {
// can do SOMETHING.
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
// so there is no window where we can't receive them.
let center = NotificationCenter.default
@ -170,6 +191,15 @@ extension Ghostty {
name: NSWindow.didChangeScreenNotification,
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.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
@ -201,6 +231,9 @@ extension Ghostty {
ghostty_surface_set_color_scheme(surface, scheme)
}
// The UTTypes that can be dragged onto this view.
registerForDraggedTypes(Array(Self.dropTypes))
}
required init?(coder: NSCoder) {
@ -212,6 +245,11 @@ extension Ghostty {
let center = NotificationCenter.default
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
// state is invalid to prevent the surface from being restored.
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
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
@ -764,16 +826,51 @@ extension Ghostty {
// know if these events cleared it.
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])
// 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
// first because if we completed a preedit, the text will be available here
// AND we'll have a preedit.
var handled: Bool = false
if let list = keyTextAccumulator, list.count > 0 {
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.
if (markedText.length > 0 || markedTextBefore) {
handled = true
keyAction(action, event: event, preedit: markedText.string)
_ = keyAction(action, event: event, preedit: markedText.string)
}
if (!handled) {
// No text or anything, we want to handle this manually.
keyAction(action, event: event)
_ = keyAction(action, event: event)
}
}
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
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Only process key down events
if (event.type != .keyDown) {
switch (event.type) {
case .keyDown:
// Continue, we care about key down events
break
default:
// Any other key event we don't care about. I don't think its even
// possible to receive any other event type.
return false
}
// Only process events if we're focused. Some key events like C-/ macOS
// appears to send to the first view in the hierarchy rather than the
// the first responder (I don't know why). This prevents us from handling it.
// 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) {
return false
}
// Only process keys when Control is active. All known issues we're
// resolving happen only in this scenario. This probably isn't fully robust
// but we can broaden the scope as we find more cases.
if (!event.modifierFlags.contains(.control)) {
return false
// If this event as-is would result in a key binding then we send it.
if let surface,
ghostty_surface_key_is_binding(
surface,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
self.keyDown(with: event)
return true
}
let equivalent: String
@ -832,14 +940,25 @@ extension Ghostty {
case "\r":
// Pass C-<return> through verbatim
// (prevent the default context menu equivalent)
if (!event.modifierFlags.contains(.control)) {
return false
}
equivalent = "\r"
case ".":
if (!event.modifierFlags.contains(.command)) {
return false
}
equivalent = "."
default:
// Ignore other events
return false
}
let newEvent = NSEvent.keyEvent(
let finalEvent = NSEvent.keyEvent(
with: .keyDown,
location: event.locationInWindow,
modifierFlags: event.modifierFlags,
@ -852,7 +971,7 @@ extension Ghostty {
keyCode: event.keyCode
)
self.keyDown(with: newEvent!)
self.keyDown(with: finalEvent!)
return true
}
@ -867,6 +986,9 @@ extension Ghostty {
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
// but this is super cheap and flagsChanged isn't that common.
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) {
guard let surface = self.surface else { return }
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) -> Bool {
guard let surface = self.surface else { return false }
return ghostty_surface_key(surface, event.ghosttyKeyEvent(action))
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) {
guard let surface = self.surface else { return }
private func keyAction(
_ action: ghostty_input_action_e,
event: NSEvent, preedit: String
) -> Bool {
guard let surface = self.surface else { return false }
preedit.withCString { ptr in
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
return preedit.withCString { ptr in
var key_ev = event.ghosttyKeyEvent(action)
key_ev.text = ptr
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) {
guard let surface = self.surface else { return }
private func keyAction(
_ action: ghostty_input_action_e,
event: NSEvent, text: String
) -> Bool {
guard let surface = self.surface else { return false }
text.withCString { ptr in
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
return text.withCString { ptr in
var key_ev = event.ghosttyKeyEvent(action)
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?) {
guard let surface = self.surface else { return }
let action = "select_all"
@ -1374,3 +1497,78 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
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 OSColor = NSColor
typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView
@ -34,6 +35,7 @@ import UIKit
typealias OSView = UIView
typealias OSColor = UIColor
typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
protocol OSViewRepresentable: UIViewRepresentable {
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
private func hideDock() {
NSApp.presentationOptions.insert(.autoHideDock)
NSApp.acquirePresentationOption(.autoHideDock)
}
private func unhideDock() {
NSApp.presentationOptions.remove(.autoHideDock)
NSApp.releasePresentationOption(.autoHideDock)
}
// MARK: Menu
func hideMenu() {
NSApp.presentationOptions.insert(.autoHideMenuBar)
NSApp.acquirePresentationOption(.autoHideMenuBar)
}
func unhideMenu() {
NSApp.presentationOptions.remove(.autoHideMenuBar)
NSApp.releasePresentationOption(.autoHideMenuBar)
}
/// 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 GhosttyKit
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.
/// 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.
/// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? {
if let file = self.string(forType: .fileURL) {
if let path = NSURL(string: file)?.path {
return path
}
if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
urls.count > 0 {
return urls
.map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString }
.joined(separator: " ")
}
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,
hyperfine,
typos,
wayland,
wayland-scanner,
wayland-protocols,
}: let
# See package.nix. Keep in sync.
rpathLibs =
@ -80,6 +83,7 @@
libadwaita
gtk4
glib
wayland
];
in
mkShell {
@ -153,6 +157,9 @@ in
libadwaita
gtk4
glib
wayland
wayland-scanner
wayland-protocols
];
# This should be set onto the rpath of the ghostty binary if you want

View File

@ -10,10 +10,6 @@
oniguruma,
zlib,
libGL,
libX11,
libXcursor,
libXi,
libXrandr,
glib,
gtk4,
libadwaita,
@ -26,7 +22,15 @@
pandoc,
revision ? "dirty",
optimize ? "Debug",
x11 ? true,
enableX11 ? true,
libX11,
libXcursor,
libXi,
libXrandr,
enableWayland ? true,
wayland,
wayland-protocols,
wayland-scanner,
}: let
# The Zig hook has no way to select the release type without actual
# overriding of the default flags.
@ -49,7 +53,6 @@
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
lib.fileset.unions [
../dist/linux
../conformance
../images
../include
../pkg
@ -114,14 +117,19 @@ in
version = "1.0.2";
inherit src;
nativeBuildInputs = [
git
ncurses
pandoc
pkg-config
zig_hook
wrapGAppsHook4
];
nativeBuildInputs =
[
git
ncurses
pandoc
pkg-config
zig_hook
wrapGAppsHook4
]
++ lib.optionals enableWayland [
wayland-scanner
wayland-protocols
];
buildInputs =
[
@ -142,16 +150,19 @@ in
glib
gsettings-desktop-schemas
]
++ lib.optionals x11 [
++ lib.optionals enableX11 [
libX11
libXcursor
libXi
libXrandr
]
++ lib.optionals enableWayland [
wayland
];
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 = ''
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
# 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 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
if (freetype_enabled) {
if (b.systemIntegrationOption("freetype", .{})) {
lib.linkSystemLibrary2("freetype", dynamic_link_opts);
lib.linkSystemLibrary2("freetype2", dynamic_link_opts);
} else {
const freetype_dep = b.dependency(
"freetype",

View File

@ -5,7 +5,61 @@ pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{});
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 lib = b.addStaticLibrary(.{
@ -21,16 +75,6 @@ pub fn build(b: *std.Build) !void {
}
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);
defer flags.deinit();
try flags.appendSlice(&.{
@ -44,6 +88,8 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize=undefined",
});
const dynamic_link_opts = options.dynamic_link_opts;
// Zlib
if (b.systemIntegrationOption("zlib", .{})) {
lib.linkSystemLibrary2("zlib", dynamic_link_opts);
@ -113,18 +159,7 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib);
if (target.query.isNative()) {
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);
}
return lib;
}
const srcs: []const []const u8 = &.{

View File

@ -43,7 +43,11 @@ pub fn build(b: *std.Build) !void {
{
var it = module.import_table.iterator();
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 test_step = b.step("test", "Run tests");
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 optimize = options.optimize;

View File

@ -18,9 +18,72 @@ pub const ColorSpace = opaque {
) 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 {
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 {

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 optimize = options.optimize;

View File

@ -162,4 +162,26 @@ pub const Binding = struct {
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(
&image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
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(
decoder,

View File

@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
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(
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).
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
/// same font configuration.
font_grid_set: font.SharedGridSet,
@ -98,7 +95,6 @@ pub fn create(
.alloc = alloc,
.surfaces = .{},
.mailbox = .{},
.quit = false,
.font_grid_set = font_grid_set,
.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
/// events. This should be called by the application runtime on every loop
/// tick.
///
/// This returns whether the app should quit or not.
pub fn tick(self: *App, rt_app: *apprt.App) !bool {
pub fn tick(self: *App, rt_app: *apprt.App) !void {
// If any surfaces are closing, destroy them
var i: usize = 0;
while (i < self.surfaces.items.len) {
@ -142,13 +136,6 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool {
// Drain our mailbox
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
@ -272,7 +259,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
// can try to quit as quickly as possible.
.quit => {
log.info("quit message received, short circuiting mailbox drain", .{});
self.setQuit();
try self.performAction(rt_app, .quit);
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
/// the focus state of the entire app containing Ghostty changes.
/// 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;
}
/// 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,
/// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false.
@ -437,7 +437,7 @@ pub fn performAction(
switch (action) {
.unbind => unreachable,
.ignore => {},
.quit => self.setQuit(),
.quit => try rt_app.performAction(.app, .quit, {}),
.new_window => try self.newWindow(rt_app, .{ .parent = null }),
.open_config => try rt_app.performAction(.app, .open_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
// but is otherwise somewhat arbitrary.
const min_window_width_cells: u32 = 10;
const min_window_height_cells: u32 = 4;
try rt_app.performAction(
.{ .surface = self },
.size_limit,
.{
.min_width = size.cell.width * 10,
.min_height = size.cell.height * 4,
.min_width = size.cell.width * min_window_width_cells,
.min_height = size.cell.height * min_window_height_cells,
// No max:
.max_width = 0,
.max_height = 0,
@ -617,8 +621,8 @@ pub fn init(
// start messing with the window.
if (config.@"window-height" > 0 and config.@"window-width" > 0) init: {
const scale = rt_surface.getContentScale() catch break :init;
const height = @max(config.@"window-height" * cell_size.height, 480);
const width = @max(config.@"window-width" * cell_size.width, 640);
const height = @max(config.@"window-height", min_window_height_cells) * cell_size.height;
const width = @max(config.@"window-width", min_window_width_cells) * cell_size.width;
const width_f32: f32 = @floatFromInt(width);
const height_f32: f32 = @floatFromInt(height);
@ -1037,6 +1041,9 @@ fn mouseRefreshLinks(
pos_vp: terminal.point.Coordinate,
over_link: bool,
) !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;
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 x: f64 = x: {
// Simple x * cell width gives the top-left corner
var x: f64 = @floatFromInt(cursor.x * self.size.cell.width);
// Simple x * cell width gives the top-left corner, then add padding offset
var x: f64 = @floatFromInt(cursor.x * self.size.cell.width + self.size.padding.left);
// We want the midpoint
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: {
// Simple x * cell width gives the top-left corner
var y: f64 = @floatFromInt(cursor.y * self.size.cell.height);
// Simple y * cell height gives the top-left corner, then add padding offset
var y: f64 = @floatFromInt(cursor.y * self.size.cell.height + self.size.padding.top);
// We want the bottom
y += @floatFromInt(self.size.cell.height);
@ -1587,6 +1594,15 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
self.renderer_state.mutex.lock();
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
if (self.renderer_state.preedit) |p| {
self.alloc.free(p.codepoints);
@ -1637,6 +1653,31 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
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
/// sending to the terminal, etc.
pub fn keyCallback(
@ -3525,22 +3566,21 @@ fn dragLeftClickTriple(
const screen = &self.io.terminal.screen;
const click_pin = self.mouse.left_click_pin.?.*;
// Get the word under our current point. If there isn't a word, do nothing.
const word = screen.selectLine(.{ .pin = drag_pin }) orelse return;
// Get the line selection under our current drag point. If there isn't a
// 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.
// We may not have a selection if we started our dbl-click in an area
// that had no data, then we dragged our mouse into an area with data.
var sel = screen.selectLine(.{ .pin = click_pin }) orelse {
try self.setSelection(word);
return;
};
// Get the selection under our click point. We first try to trim
// whitespace if we've selected a word. But if no word exists then
// we select the blank line.
const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
screen.selectLine(.{ .pin = click_pin, .whitespace = null });
// Grow our selection
var sel = sel_ orelse return;
if (drag_pin.before(click_pin)) {
sel.startPtr().* = word.start();
sel.startPtr().* = line.start();
} else {
sel.endPtr().* = word.end();
sel.endPtr().* = line.end();
}
try self.setSelection(sel);
}
@ -3907,6 +3947,33 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
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(
.standard,
.{ .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,
.next_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(
.{ .surface = self },
.toggle_fullscreen,
@ -4231,6 +4310,7 @@ fn closingAction(action: input.Binding.Action) bool {
return switch (action) {
.close_surface,
.close_window,
.close_tab,
=> true,
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
// added. Ensure the order matches exactly with the Zig code.
/// Quit the application.
quit,
/// Open a new window. The target determines whether properties such
/// as font size should be inherited.
new_window,
@ -79,6 +82,9 @@ pub const Action = union(Key) {
/// the tab should be opened in a new window.
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
/// relative to the target.
new_split: SplitDirection,
@ -86,6 +92,9 @@ pub const Action = union(Key) {
/// Close all open windows.
close_all_windows,
/// Toggle maximized window state.
toggle_maximize,
/// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen,
@ -219,10 +228,13 @@ pub const Action = union(Key) {
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
new_window,
new_tab,
close_tab,
new_split,
close_all_windows,
toggle_maximize,
toggle_fullscreen,
toggle_tab_overview,
toggle_window_decorations,

View File

@ -147,12 +147,12 @@ pub const App = struct {
self.core_app.focusEvent(focused);
}
/// See CoreApp.keyEvent.
pub fn keyEvent(
/// Convert a C key event into a Zig key event.
fn coreKeyEvent(
self: *App,
target: KeyTarget,
event: KeyEvent,
) !bool {
) !?input.KeyEvent {
const action = event.action;
const keycode = event.keycode;
const mods = event.mods;
@ -199,6 +199,11 @@ pub const App = struct {
// This logic only applies to macOS.
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
// the event text because we want to do our own translation so
// we can handle ctrl+c, ctrl+z, etc.
@ -243,7 +248,7 @@ pub const App = struct {
result.text,
) catch |err| {
log.err("error in preedit callback err={}", .{err});
return false;
return null;
},
}
} else {
@ -251,7 +256,7 @@ pub const App = struct {
.app => {},
.surface => |surface| surface.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
return false;
return null;
},
}
@ -335,7 +340,7 @@ pub const App = struct {
} else .invalid;
// Build our final key event
const input_event: input.KeyEvent = .{
return .{
.action = action,
.key = key,
.physical_key = physical_key,
@ -345,24 +350,39 @@ pub const App = struct {
.utf8 = result.text,
.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.
const effect: CoreSurface.InputEffect = switch (target) {
.app => if (self.core_app.keyEvent(
self,
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) {
.closed => true,
.ignored => false,
.consumed => consumed: {
const is_down = input_event.action == .press or
input_event.action == .repeat;
if (is_down) {
// If we consume the key then we want to reset the dead
// key state.
@ -618,7 +638,7 @@ pub const Surface = struct {
.y = @floatCast(opts.scale_factor),
},
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.cursor_pos = .{ .x = -1, .y = -1 },
.keymap_state = .{},
};
@ -1332,10 +1352,9 @@ pub const CAPI = struct {
/// Tick the event loop. This should be called whenever the "wakeup"
/// callback is invoked for the runtime.
export fn ghostty_app_tick(v: *App) bool {
return v.core_app.tick(v) catch |err| err: {
export fn ghostty_app_tick(v: *App) void {
v.core_app.tick(v) catch |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
/// keyboard layout to be reloaded from the OS.
export fn ghostty_app_keyboard_changed(v: *App) void {
@ -1592,16 +1633,38 @@ pub const CAPI = struct {
export fn ghostty_surface_key(
surface: *Surface,
event: KeyEvent,
) void {
_ = surface.app.keyEvent(
) bool {
return surface.app.keyEvent(
.{ .surface = surface },
event.keyEvent(),
) catch |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
/// so this isn't useful for sending escape sequences. For that,
/// individual key input should be used.
@ -1895,7 +1958,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
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,
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.
darwin: if (Darwin.enabled) Darwin else void,
@ -124,8 +128,10 @@ pub const App = struct {
glfw.waitEvents();
// Tick the terminal app
const should_quit = try self.app.tick(self);
if (should_quit or self.app.surfaces.items.len == 0) {
try self.app.tick(self);
// 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| {
surface.close(false);
}
@ -149,6 +155,8 @@ pub const App = struct {
value: apprt.Action.Value(action),
) !void {
switch (action) {
.quit => self.quit = true,
.new_window => _ = try self.newSurface(switch (target) {
.app => null,
.surface => |v| v,
@ -210,6 +218,7 @@ pub const App = struct {
.toggle_split_zoom,
.present_terminal,
.close_all_windows,
.close_tab,
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_quick_terminal,
@ -228,6 +237,7 @@ pub const App = struct {
.color_change,
.pwd,
.config_change,
.toggle_maximize,
=> log.info("unimplemented action={}", .{action}),
}
}

View File

@ -36,7 +36,7 @@ const c = @import("c.zig").c;
const version = @import("version.zig");
const inspector = @import("inspector.zig");
const key = @import("key.zig");
const x11 = @import("x11.zig");
const winproto = @import("winproto.zig");
const testing = std.testing;
const log = std.log.scoped(.gtk);
@ -49,6 +49,9 @@ config: Config,
app: *c.GtkApplication,
ctx: *c.GMainContext,
/// State and logic for the underlying windowing protocol.
winproto: winproto.App,
/// True if the app was launched with single instance mode.
single_instance: bool,
@ -70,8 +73,10 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// This is set to false when the main loop should exit.
running: bool = true,
/// Xkb state (X11 only). Will be null on Wayland.
x11_xkb: ?x11.Xkb = null,
/// If we should retry querying D-Bus for the color scheme with the deprecated
/// 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
/// 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(),
});
// 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
var config = try Config.load(core_app.alloc);
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();
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 (adwaita.enabled(&config)) {
@ -360,42 +432,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
return error.GtkApplicationRegisterFailed;
}
// Perform all X11 initialization. This ultimately returns the X11
// keyboard state but the block does more than that (i.e. setting up
// WM_CLASS).
const x11_xkb: ?x11.Xkb = x11_xkb: {
if (comptime !build_options.x11) break :x11_xkb null;
if (!x11.is_display(display)) break :x11_xkb null;
// 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.
//
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);
};
// Setup our windowing protocol logic
var winproto_app = try winproto.App.init(
core_app.alloc,
display,
app_id,
&config,
);
errdefer winproto_app.deinit(core_app.alloc);
log.debug("windowing protocol={s}", .{@tagName(winproto_app)});
// 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
@ -421,7 +466,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.config = config,
.ctx = ctx,
.cursor_none = cursor_none,
.x11_xkb = x11_xkb,
.winproto = winproto_app,
.single_instance = single_instance,
// 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
@ -449,6 +494,8 @@ pub fn terminate(self: *App) void {
}
self.custom_css_providers.deinit(self.core_app.alloc);
self.winproto.deinit(self.core_app.alloc);
self.config.deinit();
}
@ -460,13 +507,16 @@ pub fn performAction(
value: apprt.Action.Value(action),
) !void {
switch (action) {
.quit => self.quit(),
.new_window => _ = try self.newWindow(switch (target) {
.app => null,
.surface => |v| v,
}),
.toggle_maximize => self.toggleMaximize(target),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
.close_tab => try self.closeTab(target),
.goto_tab => self.gotoTab(target, value),
.move_tab => self.moveTab(target, value),
.new_split => try self.newSplit(target, value),
@ -482,6 +532,7 @@ pub fn performAction(
.pwd => try self.setPwd(target, value),
.present_terminal => self.presentTerminal(target),
.initial_size => try self.setInitialSize(target, value),
.size_limit => try self.setSizeLimit(target, value),
.mouse_visibility => self.setMouseVisibility(target, value),
.mouse_shape => try self.setMouseShape(target, value),
.mouse_over_link => self.setMouseOverLink(target, value),
@ -494,7 +545,6 @@ pub fn performAction(
.close_all_windows,
.toggle_quick_terminal,
.toggle_visibility,
.size_limit,
.cell_size,
.secure_input,
.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 {
switch (target) {
.app => {},
@ -648,6 +715,22 @@ fn controlInspector(
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(
_: *App,
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(
self: *App,
target: apprt.Target,
@ -837,9 +937,12 @@ fn configChange(
new_config: *const Config,
) void {
switch (target) {
// We don't do anything for surface config change events. There
// is nothing to sync with regards to a surface today.
.surface => {},
.surface => |surface| surface: {
const window = surface.rt_surface.container.window() orelse break :surface;
window.updateConfig(new_config) catch |err| {
log.warn("error updating config for window err={}", .{err});
};
},
.app => {
// We clone (to take ownership) and update our configuration.
@ -995,7 +1098,28 @@ fn loadRuntimeCss(
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) {
.ghostty => try writer.print(
\\:root {{
@ -1008,6 +1132,8 @@ fn loadRuntimeCss(
\\ --overview-bg-color: var(--ghostty-bg);
\\ --popover-fg-color: var(--ghostty-fg);
\\ --popover-bg-color: var(--ghostty-bg);
\\ --window-fg-color: var(--ghostty-fg);
\\ --window-bg-color: var(--ghostty-bg);
\\}}
\\windowhandle {{
\\ background-color: var(--headerbar-bg-color);
@ -1150,7 +1276,8 @@ pub fn run(self: *App) !void {
self.transient_cgroup_base = path;
} 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();
// Setup our menu items
@ -1158,9 +1285,6 @@ pub fn run(self: *App) !void {
self.initMenu();
self.initContextMenu();
// Setup our initial color scheme
self.colorSchemeEvent(self.getColorScheme());
// 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
// state.
@ -1172,14 +1296,10 @@ pub fn run(self: *App) !void {
_ = c.g_main_context_iteration(self.ctx, 1);
// 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.
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 (!self.config.@"quit-after-last-window-closed") break :q false;
@ -1212,6 +1332,22 @@ fn initDbus(self: *App) void {
self,
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
@ -1283,6 +1419,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !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.
const list = c.gtk_window_list_toplevels();
if (list == null) {
@ -1446,93 +1585,58 @@ fn gtkWindowIsActive(
core_app.focusEvent(false);
}
/// Call a D-Bus method to determine the current color scheme. If there
/// is any error at any point we'll log the error and return "light"
pub fn getColorScheme(self: *App) apprt.ColorScheme {
const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app));
fn dbusColorSchemeCallback(
source_object: [*c]c.GObject,
res: ?*c.GAsyncResult,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud.?));
const dbus: *c.GDBusConnection = @ptrCast(source_object);
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",
"ReadOne",
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| {
// 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();
if (c.g_dbus_connection_call_finish(dbus, res, &err)) |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 c.g_variant_unref(inner);
if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) {
self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1)
.dark
else
.light);
return;
}
// Otherwise, log the error and return .light
log.err("unable to get current color scheme: {s}", .{e.message});
}
return .light;
};
defer c.g_variant_unref(value);
} else 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 (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) {
var inner: ?*c.GVariant = null;
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;
}
// Otherwise, log the error and return .light
log.warn("unable to get current color scheme: {s}", .{e.message});
}
return .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;
// Fall back
self.colorSchemeEvent(.light);
}
/// This will be called by D-Bus when the style changes between light & dark.
@ -1623,7 +1727,9 @@ fn gtkActionQuit(
ud: ?*anyopaque,
) callconv(.C) void {
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
@ -1695,18 +1801,17 @@ fn initActions(self: *App) void {
}
}
/// This sets the self.menu property to the application menu that can be
/// shared by all application windows.
fn initMenu(self: *App) void {
const menu = c.g_menu_new();
errdefer c.g_object_unref(menu);
/// Initializes and populates the provided GMenu with sections and actions.
/// This function is used to set up the application's menu structure, either for
/// the main menu button or as a context menu when window decorations are disabled.
fn initMenuContent(menu: *c.GMenu) void {
{
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, "New Window", "win.new_window");
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 Down", "win.split_down");
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, "About Ghostty", "win.about");
}
}
// {
// const section = c.g_menu_new();
// defer c.g_object_unref(section);
// c.g_menu_append_submenu(menu, "File", @ptrCast(@alignCast(section)));
// }
/// This sets the self.menu property to the application menu that can be
/// shared by all application windows.
fn initMenu(self: *App) void {
const menu = c.g_menu_new();
errdefer c.g_object_unref(menu);
initMenuContent(@ptrCast(menu));
self.menu = menu;
}
@ -1735,7 +1841,13 @@ fn initContextMenu(self: *App) void {
const menu = c.g_menu_new();
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();
@ -1753,21 +1865,21 @@ fn initContextMenu(self: *App) void {
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;
}
fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void {
const section = c.g_menu_new();
defer c.g_object_unref(section);
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);
pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void {
const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy"));
c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0);
}
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_default_size(gtk_window, 550, 275);
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.g_signal_connect_data(
window,
@ -88,6 +89,8 @@ fn init(
const view = try PrimaryView.init(self, data);
self.view = view;
c.gtk_window_set_child(@ptrCast(window), view.root);
_ = c.gtk_widget_grab_focus(view.buttons.cancel_button);
c.gtk_widget_show(window);
// Block the main window from input.
@ -103,6 +106,7 @@ fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
const PrimaryView = struct {
root: *c.GtkWidget,
text: *c.GtkTextView,
buttons: ButtonsView,
pub fn init(root: *ClipboardConfirmation, data: []const u8) !PrimaryView {
// 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_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.
@ -157,6 +161,8 @@ const PrimaryView = struct {
const ButtonsView = struct {
root: *c.GtkWidget,
confirm_button: *c.GtkWidget,
cancel_button: *c.GtkWidget,
pub fn init(root: *ClipboardConfirmation) !ButtonsView {
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);
errdefer c.g_object_unref(confirm_button);
// TODO: Focus on the paste button
// c.gtk_widget_grab_focus(confirm_button);
c.gtk_widget_add_css_class(confirm_button, "destructive-action");
c.gtk_widget_add_css_class(cancel_button, "suggested-action");
// Create our view
const view = try View.init(&.{
@ -197,7 +203,7 @@ const ButtonsView = struct {
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 {

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_resizable(gtk_window, 0);
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.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 gtk_key = @import("key.zig");
const c = @import("c.zig").c;
const x11 = @import("x11.zig");
const log = std.log.scoped(.gtk_surface);
@ -347,6 +346,11 @@ cursor: ?*c.GdkCursor = null,
/// pass it to GTK.
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.
update_title_timer: ?c.guint = null,
@ -364,10 +368,9 @@ cursor_pos: apprt.CursorPos,
inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions.
in_keypress: bool = false,
in_keyevent: bool = false,
im_context: *c.GtkIMContext,
im_composing: bool = false,
im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined,
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_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.
const font_size: ?font.face.DesiredSize = font_size: {
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,
.init_config = init_config,
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.cursor_pos = .{ .x = -1, .y = -1 },
.im_context = im_context,
.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-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(drop_target, "drop", c.G_CALLBACK(&gtkDrop), self, null, c.G_CONNECT_DEFAULT);
}
fn realize(self: *Surface) !void {
@ -618,9 +633,6 @@ fn realize(self: *Surface) !void {
try self.core_surface.setFontSize(size);
}
// Set the initial color scheme
try self.core_surface.colorSchemeCallback(self.app.getColorScheme());
// Note we're realized
self.realized = true;
}
@ -628,6 +640,7 @@ fn realize(self: *Surface) !void {
pub fn deinit(self: *Surface) void {
self.init_config.deinit(self.app.core_app.alloc);
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.
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 {
if (self.container.tab()) |tab| {
// 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
// cursor after setting the title but it doesn't work, I think
// 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;
}
/// 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 {
// If we have a tab and are the focused child, then we have to update the tab
if (self.container.tab()) |tab| {
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(
@ -1080,6 +1131,13 @@ pub fn setClipboardString(
if (!confirm) {
const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type);
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;
}
@ -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);
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)));
}
@ -1321,6 +1379,12 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
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();
}
}
@ -1426,31 +1490,37 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y),
};
// When the GLArea is resized under the mouse, GTK issues a mouse motion
// event. This has the unfortunate side effect of causing focus to potentially
// change when `focus-follows-mouse` is enabled. To prevent this, we check
// if the cursor is still in the same place as the last event and only grab
// focus if it has moved.
// There seem to be at least two cases where GTK issues a mouse motion
// event without the cursor actually moving:
// 1. GLArea is resized under the mouse. This has the unfortunate
// side effect of causing focus to potentially change when
// `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
@abs(self.cursor_pos.y - pos.y) < 1;
// If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
self.grabFocus();
if (!is_cursor_still) {
// If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
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(
@ -1530,30 +1600,36 @@ fn gtkKeyReleased(
)) 1 else 0;
}
/// Key press event. This is where we do ALL of our key handling,
/// translation to keyboard layouts, dead key handling, etc. Key handling
/// is complicated so this comment will explain what's going on.
/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
/// pass that to `keyCallback`. At a low level, this is more complicated
/// than it appears because we need to construct all of this information
/// and its not given to us.
///
/// For press events, we run the keypress through the input method context
/// in order to determine if we're in a dead key state, completed unicode
/// char, etc. This all happens through various callbacks: preedit, commit,
/// etc. These inspect "in_keypress" if they have to and set some instance
/// state.
/// For all events, we run the GdkEvent through the input method context.
/// This allows the input method to capture the event and trigger
/// callbacks such as preedit, commit, etc.
///
/// 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
/// get the underlying logical key, etc.
///
/// Finally, 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.
/// Then we can emit the keyCallback.
pub fn keyEvent(
self: *Surface,
action: input.Action,
@ -1562,26 +1638,15 @@ pub fn keyEvent(
keycode: c.guint,
gtk_mods: c.GdkModifierType,
) bool {
// log.warn("GTKIM: keyEvent action={}", .{action});
const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key),
) orelse return false;
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
// Get the unshifted unicode value of the keyval. This is used
// 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) {
// The block below is all related to input method handling. See the function
// comment for some high level details and then the comments within
// the block for more specifics.
{
// 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
// place.
@ -1593,41 +1658,94 @@ pub fn keyEvent(
.height = 1,
});
// We mark that we're in a keypress event. We use this in our
// IM commit callback to determine if we need to send a char callback
// to the core surface or not.
self.in_keypress = true;
defer self.in_keypress = false;
// Pass the event through the IM controller. This will return true
// if the input method handled the event.
//
// Confusingly, not all events handled by the input method result
// 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.
// Filter is true if the event was handled by the IM controller.
const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0;
// log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing });
// If the input method handled the event, you would think we would
// never proceed with key encoding for Ghostty but that is not the
// case. Input methods will handle basic character encoding like
// 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
// we need to set our proper preedit state.
if (self.im_composing) preedit: {
const text = self.im_buf[0..self.im_len];
self.core_surface.preeditCallback(text) catch |err| {
log.err("error in preedit callback err={}", .{err});
break :preedit;
};
// If we were composing and now we're not it means that we committed
// the text. We also don't want to encode a key event for this.
// Example: enable Japanese input method, press "konn" and then
// press enter. The final enter should not be encoded and "konn"
// (in hiragana) should be written as "こん".
if (was_composing) return true;
// If we're composing then we don't want to send the key
// event to the core surface so we always return immediately.
if (im_handled) return true;
} else {
// If we aren't composing, then we set our preedit to
// empty no matter what.
self.core_surface.preeditCallback(null) catch {};
// 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;
// Not composing and our input method buffer is empty. This could
// mean that the input method reacted to this event by activating
// an onscreen keyboard or something equivalent. We don't know.
// But the input method handled it and didn't give us text so
// we will just assume we should not encode this. This handles a
// real scenario when ibus starts the emoji input method
// (super+.).
if (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.
// (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| {
@ -1636,11 +1754,10 @@ pub fn keyEvent(
// Get our modifier for the event
const mods: input.Mods = gtk_key.eventMods(
@ptrCast(self.gl_area),
event,
physical_key,
gtk_mods,
if (self.app.x11_xkb) |*xkb| xkb else null,
&self.app.winproto,
);
// Get our consumed modifiers
@ -1761,12 +1878,11 @@ fn gtkInputPreeditStart(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
//log.debug("preedit start", .{});
// log.warn("GTKIM: preedit start", .{});
const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// Mark that we are now composing a string with a dead key state.
// We'll record the string in the preedit-changed callback.
// Start our composing state for the input method and reset our
// input buffer to empty.
self.im_composing = true;
self.im_len = 0;
}
@ -1775,54 +1891,35 @@ fn gtkInputPreeditChanged(
ctx: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
// log.warn("GTKIM: preedit change", .{});
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.
var buf: [*c]u8 = undefined;
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
defer c.g_free(buf);
const str = std.mem.sliceTo(buf, 0);
// If our string becomes empty we ignore this. This can happen after
// a commit event when the preedit is being cleared and we don't want
// to set im_len to zero. This is safe because preeditstart always sets
// 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);
// Update our preedit state in Ghostty core
self.core_surface.preeditCallback(str) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
}
fn gtkInputPreeditEnd(
_: *c.GtkIMContext,
ud: ?*anyopaque,
) callconv(.C) void {
//log.debug("preedit end", .{});
// log.warn("GTKIM: preedit end", .{});
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;
// End our preedit state in Ghostty core
self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
}
fn gtkInputCommit(
@ -1830,38 +1927,45 @@ fn gtkInputCommit(
bytes: [*:0]u8,
ud: ?*anyopaque,
) callconv(.C) void {
// log.warn("GTKIM: input commit", .{});
const self = userdataSelf(ud.?);
const str = std.mem.sliceTo(bytes, 0);
// If we're in a key event, then we want to buffer the commit so
// that we can send the proper keycallback followed by the char
// callback.
if (self.in_keypress) {
if (str.len <= self.im_buf.len) {
@memcpy(self.im_buf[0..str.len], str);
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 {
// If we're in a keyEvent (i.e. a keyboard event) and we're not composing,
// then this is just a normal key press resulting in UTF-8 text. We
// want the keyEvent to handle this so that the UTF-8 text can be associated
// with a keyboard event.
if (!self.im_composing and self.in_keyevent) {
if (str.len > self.im_buf.len) {
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;
}
// This prevents staying in composing state after commit even though
// input method has changed.
// If we reach this point from above it means we're composing OR
// 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;
// We're not in a keypress, so this was sent from an on-screen emoji
// keyboard or something like that. Send the characters directly to
// the surface.
// End our preedit state. Well-behaved input methods do this for us
// by triggering a preedit-end event but some do not (ibus 1.5.29).
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(.{
.action = .press,
.key = .invalid,
@ -1871,7 +1975,7 @@ fn gtkInputCommit(
.composing = false,
.utf8 = str,
}) catch |err| {
log.err("error in key callback err={}", .{err});
log.warn("error in key callback err={}", .{err});
return;
};
}
@ -1889,6 +1993,12 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo
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
self.core_surface.focusCallback(true) catch |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 {
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);
}
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 window = tab.window;
window.closeTab(tab);
c.gtk_window_destroy(@ptrCast(alert));
if (response != c.GTK_RESPONSE_YES) return;
tab.remove();
}
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));
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 HeaderBar = @import("headerbar.zig").HeaderBar;
const version = @import("version.zig");
const winproto = @import("winproto.zig");
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
/// disabled using gtk-titlebar. This is either an AdwHeaderBar or
/// 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
/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0).
@ -55,6 +56,9 @@ toast_overlay: ?*c.GtkWidget,
/// See adwTabOverviewOpen for why we have this.
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 {
// Allocate a fixed pointer for our window. We try to minimize
// allocations but windows and other GUI requirements are so minimal
@ -74,11 +78,12 @@ pub fn init(self: *Window, app: *App) !void {
self.* = .{
.app = app,
.window = undefined,
.header = null,
.headerbar = undefined,
.tab_overview = null,
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
.winproto = .none,
};
// Create the window
@ -99,6 +104,7 @@ pub fn init(self: *Window, app: *App) !void {
self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty");
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");
// 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");
}
// 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.
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;
} else null;
// gtk-titlebar can be used to disable the header bar (but keep
// the window manager's decorations). We create this no matter if we
// are decorated or not because we can have a keybind to toggle the
// decorations.
if (app.config.@"gtk-titlebar") {
const header = HeaderBar.init(self);
// gtk-titlebar can be used to disable the header bar (but keep the window
// manager's decorations). We create this no matter if we are decorated or
// not because we can have a keybind to toggle the decorations.
self.headerbar.init();
// 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");
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;
{
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");
c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu)));
self.headerbar.packEnd(btn);
}
// If we are disabling decorations then disable them right away.
if (!app.config.@"window-decoration") {
c.gtk_window_set_decorated(gtk_window, 0);
// 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,
);
// Fix any artifacting that may occur in window corners.
if (app.config.@"gtk-titlebar") {
c.gtk_widget_add_css_class(window, "without-window-decoration-and-with-titlebar");
}
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);
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
// need to stick the headerbar into the content box.
if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) {
if (self.header) |h| {
c.gtk_box_append(@ptrCast(box), h.asWidget());
}
c.gtk_box_append(@ptrCast(box), self.headerbar.asWidget());
}
// 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)));
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_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 (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
_ = 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, "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);
@ -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)) {
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
if (self.header) |header| {
const header_widget = header.asWidget();
c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget);
}
c.adw_toolbar_view_add_top_bar(toolbar_view, self.headerbar.asWidget());
if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new();
@ -375,10 +361,8 @@ pub fn init(self: *Window, app: *App) !void {
box,
);
} else {
c.gtk_window_set_titlebar(gtk_window, self.headerbar.asWidget());
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);
}
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
/// menus and such. The menu is defined in App.zig but the action is defined
/// here. The string name binds them.
@ -423,11 +475,23 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu));
self.winproto.deinit(self.app.core_app.alloc);
if (self.adw_tab_overview_focus_timer) |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.
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
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);
}
/// Go to the next tab for a surface.
/// Go to the last tab for a surface.
pub fn gotoLastTab(self: *Window) void {
const max = self.notebook.nPages() -| 1;
const max = self.notebook.nPages();
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.
pub fn toggleFullscreen(self: *Window) void {
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.
pub fn toggleWindowDecorations(self: *Window) void {
const old_decorated = c.gtk_window_get_decorated(self.window) == 1;
const new_decorated = !old_decorated;
c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated));
// Fix any artifacting that may occur in window corners.
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);
}
self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") {
.auto, .client, .server => .none,
.none => .client,
};
self.updateConfig(&self.app.config) catch {};
}
/// Grabs focus on the currently selected tab.
@ -542,7 +602,7 @@ pub fn onConfigReloaded(self: *Window) void {
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;
const toast_overlay = self.toast_overlay orelse return;
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);
}
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
// sends an undefined value.
fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
@ -894,10 +1033,6 @@ fn gtkActionCopy(
log.warn("error performing binding action error={}", .{err});
return;
};
if (self.app.config.@"adw-toast".@"clipboard-copy") {
self.sendToast("Copied to clipboard");
}
}
fn gtkActionPaste(

View File

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

View File

@ -4,70 +4,55 @@ const c = @import("c.zig").c;
const Window = @import("Window.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) {
adw: *AdwHeaderBar,
gtk: *c.GtkHeaderBar,
adw: HeaderBarAdw,
gtk: HeaderBarGtk,
pub fn init(window: *Window) HeaderBar {
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&window.app.config))
{
return initAdw();
pub fn init(self: *HeaderBar) void {
const window: *Window = @fieldParentPtr("headerbar", self);
if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) {
HeaderBarAdw.init(self);
} 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 {
c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible));
switch (self) {
inline else => |v| v.setVisible(visible),
}
}
pub fn asWidget(self: HeaderBar) *c.GtkWidget {
return switch (self) {
.adw => |headerbar| @ptrCast(@alignCast(headerbar)),
.gtk => |headerbar| @ptrCast(@alignCast(headerbar)),
inline else => |v| v.asWidget(),
};
}
pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void {
switch (self) {
.adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_header_bar_pack_end(
@ptrCast(@alignCast(headerbar)),
widget,
);
},
.gtk => |headerbar| c.gtk_header_bar_pack_end(
@ptrCast(@alignCast(headerbar)),
widget,
),
inline else => |v| v.packEnd(widget),
}
}
pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void {
switch (self) {
.adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
c.adw_header_bar_pack_start(
@ptrCast(@alignCast(headerbar)),
widget,
);
},
.gtk => |headerbar| c.gtk_header_bar_pack_start(
@ptrCast(@alignCast(headerbar)),
widget,
),
inline else => |v| v.packStart(widget),
}
}
pub fn setTitle(self: HeaderBar, title: [:0]const u8) void {
switch (self) {
inline else => |v| v.setTitle(title),
}
}
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_default_size(gtk_window, 1000, 600);
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");
// Initialize our imgui widget

View File

@ -2,7 +2,7 @@ const std = @import("std");
const build_options = @import("build_options");
const input = @import("../../input.zig");
const c = @import("c.zig").c;
const x11 = @import("x11.zig");
const winproto = @import("winproto.zig");
/// Returns a GTK accelerator string from a trigger.
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
/// doesn't contain enough on its own.
pub fn eventMods(
widget: *c.GtkWidget,
event: *c.GdkEvent,
physical_key: input.Key,
gtk_mods: c.GdkModifierType,
x11_xkb: ?*x11.Xkb,
app_winproto: *winproto.App,
) input.Mods {
const device = c.gdk_event_get_device(event);
var mods = 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));
};
var mods = app_winproto.eventMods(device, gtk_mods);
mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1;
switch (physical_key) {

View File

@ -17,6 +17,14 @@ pub const NotebookAdw = struct {
/// the tab view
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 {
const window: *Window = @fieldParentPtr("notebook", notebook);
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, "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, "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 {
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;
c.adw_tab_view_close_page(self.tab_view, page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
const window = tab.window.window;
// libadw versions <= 1.3.x leak the final page view
// which causes our surface to not properly cleanup. We
// 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.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();
}
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(
_: *AdwTabView,
ud: ?*anyopaque,
@ -159,5 +205,5 @@ fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
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);
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_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(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), 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(&gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
// Tab settings
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 = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box)));
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(
@ -283,3 +283,22 @@ fn gtkNotebookCreateWindow(
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;
}
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;
}

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
/// 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
/// runtime checks if the comptime checks fail.
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 (@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
// c.GTK_MINOR_VERSION because the function gets the actual
// runtime version.
@ -44,15 +63,18 @@ test "atLeast" {
const std = @import("std");
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(!atLeast(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, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, 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(atLeast(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));
try testing.expect(fun(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, 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