Merge branch 'main' into dmehala/conemu-osc9

This commit is contained in:
Damien Mehala
2025-01-05 21:58:45 +01:00
committed by GitHub
96 changed files with 4026 additions and 1301 deletions

View File

@ -376,6 +376,41 @@ jobs:
-Dgtk-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }}
test-sentry-linux:
strategy:
fail-fast: false
matrix:
sentry: ["true", "false"]
name: Build -Dsentry=${{ matrix.sentry }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.2.0
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test Sentry Build
run: |
nix develop -c zig build -Dsentry=${{ matrix.sentry }}
test-macos:
runs-on: namespace-profile-ghostty-macos
needs: test
@ -478,3 +513,38 @@ jobs:
useDaemon: false # sometimes fails on short jobs
- name: typos check
run: nix develop -c typos
test-pkg-linux:
strategy:
fail-fast: false
matrix:
pkg: ["wuffs"]
name: Test pkg/${{ matrix.pkg }}
runs-on: namespace-profile-ghostty-sm
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@v1.2.0
with:
path: |
/nix
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v15
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Test ${{ matrix.pkg }} Build
run: |
nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test"

View File

@ -24,6 +24,8 @@ const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig");
const Version = @import("src/build/Version.zig");
const Command = @import("src/Command.zig");
const Scanner = @import("zig_wayland").Scanner;
comptime {
// This is the required Zig version for building this project. We allow
// any patch version but the major and minor must match exactly.
@ -105,19 +107,19 @@ pub fn build(b: *std.Build) !void {
"Enables the use of Adwaita when using the GTK rendering backend.",
) orelse true;
config.x11 = b.option(
bool,
"gtk-x11",
"Enables linking against X11 libraries when using the GTK rendering backend.",
) orelse x11: {
if (target.result.os.tag != .linux) break :x11 false;
var x11 = false;
var wayland = false;
if (target.result.os.tag == .linux) pkgconfig: {
var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator);
pkgconfig.stdout_behavior = .Pipe;
pkgconfig.stderr_behavior = .Pipe;
try pkgconfig.spawn();
pkgconfig.spawn() catch |err| {
std.log.warn("failed to spawn pkg-config - disabling X11 and Wayland integrations: {}", .{err});
break :pkgconfig;
};
const output_max_size = 50 * 1024;
@ -139,17 +141,44 @@ pub fn build(b: *std.Build) !void {
switch (term) {
.Exited => |code| {
if (code == 0) {
if (std.mem.indexOf(u8, stdout.items, "x11")) |_| break :x11 true;
break :x11 false;
}
if (std.mem.indexOf(u8, stdout.items, "x11")) |_| x11 = true;
if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true;
} else {
std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code });
return error.Unexpected;
}
},
inline else => |code| {
std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code });
return error.Unexpected;
},
}
}
config.x11 = b.option(
bool,
"gtk-x11",
"Enables linking against X11 libraries when using the GTK rendering backend.",
) orelse x11;
config.wayland = b.option(
bool,
"gtk-wayland",
"Enables linking against Wayland libraries when using the GTK rendering backend.",
) orelse wayland;
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,
}
};
const pie = b.option(
@ -158,6 +187,16 @@ pub fn build(b: *std.Build) !void {
"Build a Position Independent Executable. Default true for system packages.",
) orelse system_package;
const 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,
};
const conformance = b.option(
[]const u8,
"conformance",
@ -342,11 +381,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.strip = switch (optimize) {
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
},
.strip = strip,
}) else null;
// Exe
@ -669,7 +704,12 @@ pub fn build(b: *std.Build) !void {
b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png");
// Flatpaks only support icons up to 512x512.
if (!config.flatpak) {
b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png");
}
b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png");
b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png");
@ -685,6 +725,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main_c.zig"),
.optimize = optimize,
.target = target,
.strip = strip,
});
_ = try addDeps(b, lib, config);
@ -702,6 +743,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main_c.zig"),
.optimize = optimize,
.target = target,
.strip = strip,
});
_ = try addDeps(b, lib, config);
@ -1240,13 +1282,15 @@ fn addDeps(
}
// Sentry
if (config.sentry) {
const sentry_dep = b.dependency("sentry", .{
.target = target,
.optimize = optimize,
.backend = .breakpad,
});
step.root_module.addImport("sentry", sentry_dep.module("sentry"));
if (target.result.os.tag != .windows) {
// Sentry
step.linkLibrary(sentry_dep.artifact("sentry"));
try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin());
@ -1430,6 +1474,24 @@ fn addDeps(
if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
if (config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts);
if (config.wayland) {
const scanner = Scanner.create(b, .{});
const wayland = b.createModule(.{ .root_source_file = scanner.result });
const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{
.target = target,
.optimize = optimize,
});
scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml"));
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
step.root_module.addImport("wayland", wayland);
step.linkSystemLibrary2("wayland-client", dynamic_link_opts);
}
{
const gresource = @import("src/apprt/gtk/gresource.zig");

View File

@ -25,6 +25,10 @@
.url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz",
.hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25",
},
.zig_wayland = .{
.url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz",
.hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c",
},
// C libs
.cimgui = .{ .path = "./pkg/cimgui" },
@ -49,8 +53,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz",
.hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz",
.hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de",
},
.vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b",
@ -64,5 +68,9 @@
.url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a",
.hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a",
},
.plasma_wayland_protocols = .{
.url = "git+https://invent.kde.org/libraries/plasma-wayland-protocols.git?ref=master#db525e8f9da548cffa2ac77618dd0fbe7f511b86",
.hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566",
},
},
}

13
default.nix Normal file
View File

@ -0,0 +1,13 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
nodeName = lock.nodes.root.inputs.flake-compat;
in
fetchTarball {
url =
lock.nodes.${nodeName}.locked.url
or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
sha256 = lock.nodes.${nodeName}.locked.narHash;
}
) {src = ./.;})
.defaultNix

View File

@ -375,9 +375,9 @@ typedef enum {
typedef enum {
GHOSTTY_GOTO_SPLIT_PREVIOUS,
GHOSTTY_GOTO_SPLIT_NEXT,
GHOSTTY_GOTO_SPLIT_TOP,
GHOSTTY_GOTO_SPLIT_UP,
GHOSTTY_GOTO_SPLIT_LEFT,
GHOSTTY_GOTO_SPLIT_BOTTOM,
GHOSTTY_GOTO_SPLIT_DOWN,
GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e;
@ -559,6 +559,7 @@ typedef struct {
// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
@ -681,10 +682,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 +714,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 */; };
@ -71,6 +71,7 @@
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
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 */; };
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 +88,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 */; };
@ -108,8 +111,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>"; };
@ -162,6 +165,7 @@
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>"; };
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 +181,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; };
@ -263,6 +269,7 @@
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
@ -351,12 +358,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 +408,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>";
@ -611,12 +620,14 @@
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 */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@ -647,6 +658,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 */,

View File

@ -358,8 +358,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
@ -425,6 +425,15 @@ class AppDelegate: NSObject,
// 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)) {
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) {
@ -438,13 +447,7 @@ class AppDelegate: NSObject,
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
@ -486,15 +489,16 @@ class AppDelegate: NSObject,
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration.
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false {
updaterController.updater.automaticallyChecksForUpdates =
config.autoUpdate == .check || config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
config.autoUpdate == .download
} else {
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
// user-based defaults.
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
updaterController.updater.automaticallyChecksForUpdates = false
updaterController.updater.automaticallyDownloadsUpdates = false
} else if let autoUpdate = config.autoUpdate {
updaterController.updater.automaticallyChecksForUpdates =
autoUpdate == .check || autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
autoUpdate == .download
}
// Config could change keybindings, so update everything that depends on that

View File

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

View File

@ -540,11 +540,11 @@ class BaseTerminalController: NSWindowController,
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
splitMoveFocus(direction: .up)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
splitMoveFocus(direction: .down)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {

View File

@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController {
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
if (!(fullscreenStyle?.isFullscreen ?? false) &&
ghostty.config.macosTitlebarStyle == "hidden")
{
applyHiddenTitlebarStyle()
}
syncAppearance(focusedSurface.derivedConfig)
}
@ -244,7 +250,9 @@ class TerminalController: BaseTerminalController {
let backgroundColor: OSColor
if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.0)
// Similar to above, an alpha component of "0" causes compositor issues, so
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
} else {
// We don't have a focused surface or our surface doesn't border the
// top. We choose to match the color of the top-left most surface.
@ -267,6 +275,28 @@ class TerminalController: BaseTerminalController {
}
}
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
guard let window else { return }
// If we don't have both an X and Y we center.
guard let x, let y else {
window.center()
return
}
// Prefer the screen our window is being placed on otherwise our primary screen.
guard let screen = window.screen ?? NSScreen.screens.first else {
window.center()
return
}
// Orient based on the top left of the primary monitor
let frame = screen.visibleFrame
window.setFrameOrigin(.init(
x: frame.minX + CGFloat(x),
y: frame.maxY - (CGFloat(y) + window.frame.height)))
}
//MARK: - NSWindowController
override func windowWillLoad() {
@ -274,6 +304,43 @@ class TerminalController: BaseTerminalController {
shouldCascadeWindows = false
}
fileprivate func applyHiddenTitlebarStyle() {
guard let window else { return }
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,
.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.
if let themeFrame = window.contentView?.superview,
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
titleBarContainer.isHidden = true
}
}
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow else { return }
@ -325,9 +392,12 @@ class TerminalController: BaseTerminalController {
}
}
// Center the window to start, we'll move the window frame automatically
// when cascading.
window.center()
// Set our window positioning to coordinates if config value exists, otherwise
// fallback to original centering behavior
setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY,
windowDecorations: config.windowDecorations)
// Make sure our theme is set on the window so styling is correct.
if let windowTheme = config.windowTheme {
@ -365,38 +435,7 @@ class TerminalController: BaseTerminalController {
// If our titlebar style is "hidden" we adjust the style appropriately
if (config.macosTitlebarStyle == "hidden") {
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,
.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.
if let themeFrame = window.contentView?.superview,
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
titleBarContainer.isHidden = true
}
applyHiddenTitlebarStyle()
}
// In various situations, macOS automatically tabs new windows. Ghostty handles

View File

@ -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() {
@ -454,6 +438,9 @@ extension Ghostty {
// Action dispatch
switch (action.tag) {
case GHOSTTY_ACTION_QUIT:
quit(app)
case GHOSTTY_ACTION_NEW_WINDOW:
newWindow(app, target: target)
@ -559,6 +546,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:

View File

@ -150,6 +150,20 @@ extension Ghostty {
return String(cString: ptr)
}
var windowPositionX: Int16? {
guard let config = self.config else { return nil }
var v: Int16 = 0
let key = "window-position-x"
return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil
}
var windowPositionY: Int16? {
guard let config = self.config else { return nil }
var v: Int16 = 0
let key = "window-position-y"
return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil
}
var windowNewTabPosition: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -361,15 +375,26 @@ 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)
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)
var quickTerminalPosition: QuickTerminalPosition {
guard let config = self.config else { return .top }
@ -437,15 +462,14 @@ extension Ghostty {
return v;
}
var autoUpdate: AutoUpdate {
let defaultValue = AutoUpdate.check
guard let config = self.config else { return defaultValue }
var autoUpdate: AutoUpdate? {
guard let config = self.config else { return nil }
var v: UnsafePointer<Int8>? = nil
let key = "auto-update"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
guard let ptr = v else { return defaultValue }
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
guard let ptr = v else { return nil }
let str = String(cString: ptr)
return AutoUpdate(rawValue: str) ?? defaultValue
return AutoUpdate(rawValue: str)
}
var autoUpdateChannel: AutoUpdateChannel {

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

@ -51,7 +51,7 @@ extension Ghostty {
/// Returns the view that would prefer receiving focus in this tree. This is always the
/// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
let container: Container
switch (self) {
case .leaf(let leaf):
@ -64,10 +64,10 @@ extension Ghostty {
let node: SplitNode
switch (direction) {
case .previous, .top, .left:
case .previous, .up, .left:
node = container.bottomRight
case .next, .bottom, .right:
case .next, .down, .right:
node = container.topLeft
}
@ -431,12 +431,12 @@ extension Ghostty {
struct Neighbors {
var left: SplitNode?
var right: SplitNode?
var top: SplitNode?
var bottom: SplitNode?
var up: SplitNode?
var down: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well
/// but we keep track of these separately because depending on the split direction
/// of the containing node, previous may be left OR top (same for next).
/// of the containing node, previous may be left OR up (same for next).
var previous: SplitNode?
var next: SplitNode?
@ -448,8 +448,8 @@ extension Ghostty {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous,
.next: \.next,
.top: \.top,
.bottom: \.bottom,
.up: \.up,
.down: \.down,
.left: \.left,
.right: \.right,
]

View File

@ -308,7 +308,7 @@ extension Ghostty {
resizeIncrements: .init(width: 1, height: 1),
resizePublisher: container.resizeEvent,
left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
TerminalSplitNested(
node: closeableTopLeft(),
@ -318,7 +318,7 @@ extension Ghostty {
])
)
}, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
TerminalSplitNested(
node: closeableBottomRight(),

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

@ -66,7 +66,7 @@ extension Ghostty {
/// An enum that is used for the directions that a split focus event can change.
enum SplitFocusDirection {
case previous, next, top, bottom, left, right
case previous, next, up, down, left, right
/// Initialize from a Ghostty API enum.
static func from(direction: ghostty_action_goto_split_e) -> Self? {
@ -77,11 +77,11 @@ extension Ghostty {
case GHOSTTY_GOTO_SPLIT_NEXT:
return .next
case GHOSTTY_GOTO_SPLIT_TOP:
return .top
case GHOSTTY_GOTO_SPLIT_UP:
return .up
case GHOSTTY_GOTO_SPLIT_BOTTOM:
return .bottom
case GHOSTTY_GOTO_SPLIT_DOWN:
return .down
case GHOSTTY_GOTO_SPLIT_LEFT:
return .left
@ -102,11 +102,11 @@ extension Ghostty {
case .next:
return GHOSTTY_GOTO_SPLIT_NEXT
case .top:
return GHOSTTY_GOTO_SPLIT_TOP
case .up:
return GHOSTTY_GOTO_SPLIT_UP
case .bottom:
return GHOSTTY_GOTO_SPLIT_BOTTOM
case .down:
return GHOSTTY_GOTO_SPLIT_DOWN
case .left:
return GHOSTTY_GOTO_SPLIT_LEFT

View File

@ -113,6 +113,9 @@ extension Ghostty {
// A small delay that is introduced before a title change to avoid flickers
private var titleChangeTimer: 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 }
@ -170,6 +173,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)
@ -212,6 +224,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 +373,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,8 +805,23 @@ 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.
@ -773,7 +829,7 @@ extension Ghostty {
if let list = keyTextAccumulator, list.count > 0 {
handled = true
for text in list {
keyAction(action, event: event, text: text)
_ = keyAction(action, event: event, text: text)
}
}
@ -783,38 +839,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 +899,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 +930,7 @@ extension Ghostty {
keyCode: event.keyCode
)
self.keyDown(with: newEvent!)
self.keyDown(with: finalEvent!)
return true
}
@ -897,45 +975,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)
}
}

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

@ -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.
@ -114,13 +118,18 @@ in
version = "1.0.2";
inherit src;
nativeBuildInputs = [
nativeBuildInputs =
[
git
ncurses
pandoc
pkg-config
zig_hook
wrapGAppsHook4
]
++ lib.optionals enableWayland [
wayland-scanner
wayland-protocols
];
buildInputs =
@ -142,16 +151,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

@ -1,3 +1,3 @@
# This file is auto-generated! check build-support/check-zig-cache-hash.sh for
# more details.
"sha256-ot5onG1yq7EWQkNUgTNBuqvsnLuaoFs2UDS96IqgJmU="
"sha256-eUY6MS3//r6pA/w9b+E4+YqmqUbzpUfL3afJJlnMhLY="

View File

@ -1,10 +1,10 @@
.{
.name = "cimgui",
.version = "1.89.9",
.version = "1.90.6", // -docking branch
.paths = .{""},
.dependencies = .{
// This should be kept in sync with the submodule in the cimgui source
// code to be safe that they're compatible.
// code in ./vendor/ to be safe that they're compatible.
.imgui = .{
.url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz",
.hash = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402",

View File

@ -13,7 +13,56 @@ pub fn build(b: *std.Build) !void {
) orelse (target.result.os.tag != .windows);
const freetype_enabled = b.option(bool, "enable-freetype", "Build freetype") orelse true;
const module = b.addModule("fontconfig", .{ .root_source_file = b.path("main.zig") });
const module = b.addModule("fontconfig", .{
.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,
};
const 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);
if (b.systemIntegrationOption("fontconfig", .{})) {
module.linkSystemLibrary("fontconfig", dynamic_link_opts);
test_exe.linkSystemLibrary2("fontconfig", dynamic_link_opts);
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
.libxml2_enabled = libxml2_enabled,
.libxml2_iconv_enabled = libxml2_iconv_enabled,
.freetype_enabled = freetype_enabled,
.dynamic_link_opts = dynamic_link_opts,
});
test_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 libxml2_enabled = options.libxml2_enabled;
const libxml2_iconv_enabled = options.libxml2_iconv_enabled;
const freetype_enabled = options.freetype_enabled;
const upstream = b.dependency("fontconfig", .{});
const lib = b.addStaticLibrary(.{
@ -131,19 +180,13 @@ pub fn build(b: *std.Build) !void {
}
}
// 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,
};
const dynamic_link_opts = options.dynamic_link_opts;
// Freetype2
_ = 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",
@ -194,16 +237,7 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib);
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
test_exe.linkLibrary(lib);
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
return lib;
}
const headers = &.{

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

@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void {
.@"enable-libpng" = true,
});
const macos = b.dependency("macos", .{ .target = target, .optimize = optimize });
const upstream = b.dependency("harfbuzz", .{});
const module = b.addModule("harfbuzz", .{
.root_source_file = b.path("main.zig"),
@ -26,6 +25,66 @@ pub fn build(b: *std.Build) !void {
},
});
// 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,
};
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
{
var it = module.import_table.iterator();
while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*);
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);
}
if (b.systemIntegrationOption("harfbuzz", .{})) {
module.linkSystemLibrary("harfbuzz", dynamic_link_opts);
test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts);
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
.coretext_enabled = coretext_enabled,
.freetype_enabled = freetype_enabled,
.dynamic_link_opts = dynamic_link_opts,
});
test_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 coretext_enabled = options.coretext_enabled;
const freetype_enabled = options.freetype_enabled;
const freetype = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = true,
});
const upstream = b.dependency("harfbuzz", .{});
const lib = b.addStaticLibrary(.{
.name = "harfbuzz",
.target = target,
@ -41,13 +100,7 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, module);
}
// 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,
};
const dynamic_link_opts = options.dynamic_link_opts;
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
@ -102,20 +155,5 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib);
{
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
test_exe.linkLibrary(lib);
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"));
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
return lib;
}

View File

@ -5,36 +5,59 @@ pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const module = b.addModule("oniguruma", .{ .root_source_file = b.path("main.zig") });
const module = b.addModule("oniguruma", .{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
const upstream = b.dependency("oniguruma", .{});
const lib = try buildOniguruma(b, upstream, target, optimize);
module.addIncludePath(upstream.path("src"));
b.installArtifact(lib);
// 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()) {
const test_exe = b.addTest(.{
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 tests_run = b.addRunArtifact(test_exe.?);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
// Uncomment this if we're debugging tests
b.installArtifact(test_exe);
b.installArtifact(test_exe.?);
}
if (b.systemIntegrationOption("oniguruma", .{})) {
module.linkSystemLibrary("oniguruma", dynamic_link_opts);
if (test_exe) |exe| {
exe.linkSystemLibrary2("oniguruma", dynamic_link_opts);
}
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
});
if (test_exe) |exe| {
exe.linkLibrary(lib);
}
}
}
fn buildOniguruma(
b: *std.Build,
upstream: *std.Build.Dependency,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
) !*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;
const upstream = b.dependency("oniguruma", .{});
const lib = b.addStaticLibrary(.{
.name = "oniguruma",
.target = target,
@ -43,6 +66,7 @@ fn buildOniguruma(
const t = target.result;
lib.linkLibC();
lib.addIncludePath(upstream.path("src"));
module.addIncludePath(upstream.path("src"));
if (target.result.isDarwin()) {
const apple_sdk = @import("apple_sdk");
@ -134,5 +158,7 @@ fn buildOniguruma(
.{ .include_extensions = &.{".h"} },
);
b.installArtifact(lib);
return lib;
}

View File

@ -1,6 +1,6 @@
.{
.name = "simdutf",
.version = "4.0.9",
.version = "5.2.8",
.paths = .{""},
.dependencies = .{
.apple_sdk = .{ .path = "../apple-sdk" },

View File

@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void {
.file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items,
});
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
unit_tests.linkLibC();
unit_tests.addIncludePath(wuffs.path("release/c"));
unit_tests.addCSourceFile(.{
.file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items,
});
const pixels = b.dependency("pixels", .{});
inline for (.{ "000000", "FFFFFF" }) |color| {
inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| {
const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension });
unit_tests.root_module.addAnonymousImport(
filename,
.{
.root_source_file = pixels.path(filename),
},
);
}
}
const run_unit_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
}

View File

@ -3,8 +3,13 @@
.version = "0.0.0",
.dependencies = .{
.wuffs = .{
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz",
.hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462",
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz",
.hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd",
},
.pixels = .{
.url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877",
.hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806",
},
.apple_sdk = .{ .path = "../apple-sdk" },

View File

@ -1,3 +1,13 @@
const std = @import("std");
const c = @import("c.zig").c;
pub const Error = std.mem.Allocator.Error || error{WuffsError};
pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void {
if (!c.wuffs_base__status__is_ok(status)) {
const e = c.wuffs_base__status__message(status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
}

143
pkg/wuffs/src/jpeg.zig Normal file
View File

@ -0,0 +1,143 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
const Error = @import("error.zig").Error;
const check = @import("error.zig").check;
const ImageData = @import("main.zig").ImageData;
const log = std.log.scoped(.wuffs_jpeg);
/// Decode a JPEG image.
pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
// Work around some weirdness in WUFFS/Zig, there are some structs that
// are defined as "extern" by the Zig compiler which means that Zig won't
// allocate them on the stack at compile time. WUFFS has functions for
// dynamically allocating these structs but they use the C malloc/free. This
// gets around that by using the Zig allocator to allocate enough memory for
// the struct and then casts it to the appropriate pointer.
const decoder_buf = try alloc.alloc(u8, c.sizeof__wuffs_jpeg__decoder());
defer alloc.free(decoder_buf);
const decoder: ?*c.wuffs_jpeg__decoder = @ptrCast(decoder_buf);
{
const status = c.wuffs_jpeg__decoder__initialize(
decoder,
c.sizeof__wuffs_jpeg__decoder(),
c.WUFFS_VERSION,
0,
);
try check(log, &status);
}
var source_buffer: c.wuffs_base__io_buffer = .{
.data = .{ .ptr = @constCast(@ptrCast(data.ptr)), .len = data.len },
.meta = .{
.wi = data.len,
.ri = 0,
.pos = 0,
.closed = true,
},
};
var image_config: c.wuffs_base__image_config = undefined;
{
const status = c.wuffs_jpeg__decoder__decode_image_config(
decoder,
&image_config,
&source_buffer,
);
try check(log, &status);
}
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg);
const height = c.wuffs_base__pixel_config__height(&image_config.pixcfg);
c.wuffs_base__pixel_config__set(
&image_config.pixcfg,
c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL,
c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE,
width,
height,
);
const destination = try alloc.alloc(
u8,
width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul),
);
errdefer alloc.free(destination);
// temporary buffer for intermediate processing of image
const work_buffer = try alloc.alloc(
u8,
// The type of this is a u64 on all systems but our allocator
// uses a usize which is a u32 on 32-bit systems.
std.math.cast(
usize,
c.wuffs_jpeg__decoder__workbuf_len(decoder).max_incl,
) orelse return error.OutOfMemory,
);
defer alloc.free(work_buffer);
const work_slice = c.wuffs_base__make_slice_u8(
work_buffer.ptr,
work_buffer.len,
);
var pixel_buffer: c.wuffs_base__pixel_buffer = undefined;
{
const status = c.wuffs_base__pixel_buffer__set_from_slice(
&pixel_buffer,
&image_config.pixcfg,
c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
);
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,
&pixel_buffer,
&source_buffer,
c.WUFFS_BASE__PIXEL_BLEND__SRC,
work_slice,
null,
);
try check(log, &status);
}
return .{
.width = width,
.height = height,
.data = destination,
};
}
test "jpeg_decode_000000" {
const data = try decode(std.testing.allocator, @embedFile("1x1#000000.jpg"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data);
}
test "jpeg_decode_FFFFFF" {
const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.jpg"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data);
}

View File

@ -1,2 +1,15 @@
const std = @import("std");
pub const png = @import("png.zig");
pub const jpeg = @import("jpeg.zig");
pub const swizzle = @import("swizzle.zig");
pub const ImageData = struct {
width: u32,
height: u32,
data: []const u8,
};
test {
std.testing.refAllDeclsRecursive(@This());
}

View File

@ -2,15 +2,13 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
const Error = @import("error.zig").Error;
const check = @import("error.zig").check;
const ImageData = @import("main.zig").ImageData;
const log = std.log.scoped(.wuffs_png);
/// Decode a PNG image.
pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
width: u32,
height: u32,
data: []const u8,
} {
pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
// Work around some weirdness in WUFFS/Zig, there are some structs that
// are defined as "extern" by the Zig compiler which means that Zig won't
// allocate them on the stack at compile time. WUFFS has functions for
@ -29,11 +27,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
c.WUFFS_VERSION,
0,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
var source_buffer: c.wuffs_base__io_buffer = .{
@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config,
&source_buffer,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg);
@ -102,11 +92,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config.pixcfg,
c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
var frame_config: c.wuffs_base__frame_config = undefined;
@ -116,11 +102,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&frame_config,
&source_buffer,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
{
@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
work_slice,
null,
);
if (!c.wuffs_base__status__is_ok(&status)) {
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
try check(log, &status);
}
return .{
@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
.data = destination,
};
}
test "png_decode_000000" {
const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data);
}
test "png_decode_FFFFFF" {
const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png"));
defer std.testing.allocator.free(data.data);
try std.testing.expectEqual(1, data.width);
try std.testing.expectEqual(1, data.height);
try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data);
}

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

@ -18,6 +18,7 @@ const Command = @This();
const std = @import("std");
const builtin = @import("builtin");
const global_state = &@import("global.zig").state;
const internal_os = @import("os/main.zig");
const windows = internal_os.windows;
const TempDir = internal_os.TempDir;
@ -175,6 +176,10 @@ fn startPosix(self: *Command, arena: Allocator) !void {
// We don't log because that'll show up in the output.
};
// Restore any rlimits that were set by Ghostty. This might fail but
// any failures are ignored (its best effort).
global_state.rlimits.restore();
// If the user requested a pre exec callback, call it now.
if (self.pre_exec) |f| f(self);

View File

@ -1156,7 +1156,6 @@ pub fn updateConfig(
}
// If we are in the middle of a key sequence, clear it.
self.keyboard.bindings = null;
self.endKeySequence(.drop, .free);
// Before sending any other config changes, we give the renderer a new font
@ -1638,6 +1637,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(
@ -1853,9 +1877,6 @@ fn maybeHandleBinding(
if (self.keyboard.bindings != null and
!event.key.modifier())
{
// Reset to the root set
self.keyboard.bindings = null;
// Encode everything up to this point
self.endKeySequence(.flush, .retain);
}
@ -1941,10 +1962,21 @@ fn maybeHandleBinding(
return .closed;
}
// If we have the performable flag and the action was not performed,
// then we act as though a binding didn't exist.
if (leaf.flags.performable and !performed) {
// If we're in a sequence, we treat this as if we pressed a key
// that doesn't exist in the sequence. Reset our sequence and flush
// any queued events.
self.endKeySequence(.flush, .retain);
return null;
}
// If we consume this event, then we are done. If we don't consume
// it, we processed the action but we still want to process our
// encodings, too.
if (performed and consumed) {
if (consumed) {
// If we had queued events, we deinit them since we consumed
self.endKeySequence(.drop, .retain);
@ -1986,6 +2018,10 @@ fn endKeySequence(
);
};
// No matter what we clear our current binding set. This restores
// the set we look at to the root set.
self.keyboard.bindings = null;
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
@ -3889,7 +3925,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
log.err("error setting clipboard string err={}", .{err});
return true;
};
return true;
}
return false;
},
.paste_from_clipboard => try self.startClipboardRequest(

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,
@ -219,6 +222,7 @@ pub const Action = union(Key) {
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
new_window,
new_tab,
new_split,
@ -332,9 +336,9 @@ pub const GotoSplit = enum(c_int) {
previous,
next,
top,
up,
left,
bottom,
down,
right,
};

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;
@ -243,7 +243,7 @@ pub const App = struct {
result.text,
) catch |err| {
log.err("error in preedit callback err={}", .{err});
return false;
return null;
},
}
} else {
@ -251,7 +251,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 +335,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 +345,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.
@ -1332,10 +1347,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 +1386,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 +1628,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 +1953,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur-radius"),
@intCast(config.@"background-blur-radius".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,
@ -510,6 +518,13 @@ pub const Surface = struct {
) orelse return glfw.mustGetErrorCode();
errdefer win.destroy();
// Setup our
setInitialWindowPosition(
win,
app.config.@"window-position-x",
app.config.@"window-position-y",
);
// Get our physical DPI - debug only because we don't have a use for
// this but the logging of it may be useful
if (builtin.mode == .Debug) {
@ -663,6 +678,17 @@ pub const Surface = struct {
});
}
/// Set the initial window position. This is called exactly once at
/// surface initialization time. This may be called before "self"
/// is fully initialized.
fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void {
const start_position_x = x orelse return;
const start_position_y = y orelse return;
log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y });
win.setPos(.{ .x = start_position_x, .y = start_position_y });
}
/// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan
/// to use this more. i.e. you can't set max width but no max height,

View File

@ -37,6 +37,7 @@ const version = @import("version.zig");
const inspector = @import("inspector.zig");
const key = @import("key.zig");
const x11 = @import("x11.zig");
const wayland = @import("wayland.zig");
const testing = std.testing;
const log = std.log.scoped(.gtk);
@ -73,6 +74,9 @@ running: bool = true,
/// Xkb state (X11 only). Will be null on Wayland.
x11_xkb: ?x11.Xkb = null,
/// Wayland app state. Will be null on X11.
wayland: ?wayland.AppState = null,
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
@ -81,6 +85,9 @@ transient_cgroup_base: ?[]const u8 = null,
/// CSS Provider for any styles based on ghostty configuration values
css_provider: *c.GtkCssProvider,
/// Providers for loading custom stylesheets defined by user
custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{},
/// The timer used to quit the application after the last window is closed.
quit_timer: union(enum) {
off: void,
@ -108,7 +115,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// 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");
_ = 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
@ -123,7 +130,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// - "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");
_ = 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
@ -394,6 +401,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
break :x11_xkb try x11.Xkb.init(display);
};
// Initialize Wayland state
var wl = wayland.AppState.init(display);
if (wl) |*w| try w.register();
// 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
// for launching Ghostty in the "background" without immediately opening
@ -419,6 +430,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.ctx = ctx,
.cursor_none = cursor_none,
.x11_xkb = x11_xkb,
.wayland = wl,
.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
@ -441,6 +453,11 @@ pub fn terminate(self: *App) void {
if (self.context_menu) |context_menu| c.g_object_unref(context_menu);
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path);
for (self.custom_css_providers.items) |provider| {
c.g_object_unref(provider);
}
self.custom_css_providers.deinit(self.core_app.alloc);
self.config.deinit();
}
@ -452,6 +469,7 @@ 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,
@ -786,6 +804,7 @@ fn setInitialSize(
),
}
}
fn showDesktopNotification(
self: *App,
target: apprt.Target,
@ -828,9 +847,11 @@ 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| {
if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| {
log.warn("error syncing appearance changes to window err={}", .{err});
};
},
.app => {
// We clone (to take ownership) and update our configuration.
@ -892,7 +913,7 @@ fn syncConfigChanges(self: *App) !void {
try self.updateConfigErrors();
try self.syncActionAccelerators();
// Load our runtime CSS. If this fails then our window is just stuck
// Load our runtime and custom CSS. If this fails then our window is just stuck
// with the old CSS but we don't want to fail the entire sync operation.
self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn(
@ -900,6 +921,9 @@ fn syncConfigChanges(self: *App) !void {
.{},
),
};
self.loadCustomCss() catch |err| {
log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err});
};
}
/// This should be called whenever the configuration changes to update
@ -983,6 +1007,27 @@ fn loadRuntimeCss(
unfocused_fill.b,
});
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.atLeast(4, 16, 0)) {
switch (window_theme) {
.ghostty => try writer.print(
@ -1033,11 +1078,66 @@ fn loadRuntimeCss(
}
// Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data(
self.css_provider,
buf.items.ptr,
@intCast(buf.items.len),
loadCssProviderFromData(self.css_provider, buf.items);
}
fn loadCustomCss(self: *App) !void {
const display = c.gdk_display_get_default();
// unload the previously loaded style providers
for (self.custom_css_providers.items) |provider| {
c.gtk_style_context_remove_provider_for_display(
display,
@ptrCast(provider),
);
c.g_object_unref(provider);
}
self.custom_css_providers.clearRetainingCapacity();
for (self.config.@"gtk-custom-css".value.items) |p| {
const path, const optional = switch (p) {
.optional => |path| .{ path, true },
.required => |path| .{ path, false },
};
const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
if (err != error.FileNotFound or !optional) {
log.err("error opening gtk-custom-css file {s}: {}", .{ path, err });
}
continue;
};
defer file.close();
log.info("loading gtk-custom-css path={s}", .{path});
const contents = try file.reader().readAllAlloc(self.core_app.alloc, 5 * 1024 * 1024 // 5MB
);
defer self.core_app.alloc.free(contents);
const provider = c.gtk_css_provider_new();
c.gtk_style_context_add_provider_for_display(
display,
@ptrCast(provider),
c.GTK_STYLE_PROVIDER_PRIORITY_USER,
);
loadCssProviderFromData(provider, contents);
try self.custom_css_providers.append(self.core_app.alloc, provider);
}
}
fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void {
if (version.atLeast(4, 12, 0)) {
const g_bytes = c.g_bytes_new(data.ptr, data.len);
defer c.g_bytes_unref(g_bytes);
c.gtk_css_provider_load_from_bytes(provider, g_bytes);
} else {
c.gtk_css_provider_load_from_data(
provider,
data.ptr,
@intCast(data.len),
);
}
}
/// Called by CoreApp to wake up the event loop.
@ -1105,14 +1205,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;
@ -1216,6 +1312,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) {
@ -1556,7 +1655,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
@ -1575,7 +1676,7 @@ fn gtkActionPresentSurface(
// Convert that u64 to pointer to a core surface. A value of zero
// means that there was no target surface for the notification so
// we dont' focus any surface.
// we don't focus any surface.
const ptr_int: u64 = c.g_variant_get_uint64(parameter);
if (ptr_int == 0) return;
const surface: *CoreSurface = @ptrFromInt(ptr_int);

View File

@ -64,6 +64,8 @@ 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,
"destroy",

View File

@ -55,6 +55,8 @@ 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);
// Set some state

View File

@ -111,16 +111,6 @@ pub fn init(
// Keep a long-lived reference, which we unref in destroy.
_ = c.g_object_ref(paned);
// Clicks
const gesture_click = c.gtk_gesture_click_new();
errdefer c.g_object_unref(gesture_click);
c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE);
c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1);
c.gtk_widget_add_controller(paned, @ptrCast(gesture_click));
// Signals
_ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(&gtkMouseDown), self, null, c.G_CONNECT_DEFAULT);
// Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location
@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 {
return weight;
}
fn gtkMouseDown(
_: *c.GtkGestureClick,
n_press: c.gint,
_: c.gdouble,
_: c.gdouble,
ud: ?*anyopaque,
) callconv(.C) void {
if (n_press == 2) {
const self: *Split = @ptrCast(@alignCast(ud));
_ = equalize(self);
}
}
// maxPosition returns the maximum position of the GtkPaned, which is the
// "max-position" attribute.
fn maxPosition(self: *Split) f64 {
@ -339,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
// This behavior matches the behavior of macOS at the time of writing
// this. There is an open issue (#524) to make this depend on the
// actual physical location of the current split.
result.put(.top, prev.surface);
result.put(.up, prev.surface);
result.put(.left, prev.surface);
}
}
@ -347,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap {
if (self.directionNext(from)) |next| {
result.put(.next, next.surface);
if (!next.wrapped) {
result.put(.bottom, next.surface);
result.put(.down, next.surface);
result.put(.right, next.surface);
}
}

View File

@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
// can support fractional scaling.
const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area)));
// If we are on X11, we also have to scale using Xft.dpi
const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: {
// Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied
// by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
// Also scale using font-specific DPI, which is often exposed to the user
// via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
const xft_dpi_scale = xft_scale: {
// gtk-xft-dpi is font DPI multiplied by 1024. See
// https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
const settings = c.gtk_settings_get_default();
var value: c.GValue = std.mem.zeroes(c.GValue);
@ -806,10 +807,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value);
const gtk_xft_dpi = c.g_value_get_int(&value);
// As noted above Xft.dpi is multiplied by 1024, so we divide by 1024,
// then divide by the default value of Xft.dpi (96) to derive a scale.
// Note that gtk-xft-dpi can be fractional, so we use floating point
// math here.
// As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
// 1024, then divide by the default value (96) to derive a scale. Note
// gtk-xft-dpi can be fractional, so we use floating point math here.
const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024;
break :xft_scale xft_dpi / 96;
};
@ -1080,6 +1080,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.@"adw-toast".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
}
return;
}
@ -1426,15 +1433,23 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y),
};
// Our pos changed, update
self.cursor_pos = pos;
// 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.
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 (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") {
if (!is_cursor_still and 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);

View File

@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
// Set the userdata of the box to point to this tab.
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self);
try window.notebook.addTab(self, "Ghostty");
window.notebook.addTab(self, "Ghostty");
// Attach all events
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);

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 wayland = @import("wayland.zig");
const log = std.log.scoped(.gtk);
@ -55,6 +56,8 @@ toast_overlay: ?*c.GtkWidget,
/// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c.guint = null,
wayland: ?wayland.SurfaceState,
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
@ -79,6 +82,7 @@ pub fn init(self: *Window, app: *App) !void {
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
.wayland = null,
};
// Create the window
@ -99,6 +103,8 @@ 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
// to disable this so that terminal programs can capture F10 (such as htop)
@ -113,21 +119,16 @@ 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);
// Setup our notebook
self.notebook = Notebook.create(self);
self.notebook.init();
// If we are using Adwaita, then we can support the tab overview.
self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: {
const tab_overview = c.adw_tab_overview_new();
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data(
tab_overview,
@ -189,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void {
.hidden => btn: {
const btn = c.adw_tab_button_new();
c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view);
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;
},
@ -267,8 +268,8 @@ pub fn init(self: *Window, app: *App) !void {
// If we have a tab overview then we can set it on our notebook.
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable;
assert(self.notebook == .adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
assert(self.notebook == .adw);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view);
}
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu)));
@ -288,6 +289,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);
@ -305,7 +307,7 @@ pub fn init(self: *Window, app: *App) !void {
if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new();
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view);
c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view);
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
@ -338,9 +340,8 @@ pub fn init(self: *Window, app: *App) !void {
);
} else tab_bar: {
switch (self.notebook) {
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
.adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) {
if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar;
// In earlier adwaita versions, we need to add the tabbar manually since we do not use
// an AdwToolbarView.
const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?;
@ -360,12 +361,12 @@ pub fn init(self: *Window, app: *App) !void {
),
.hidden => unreachable,
}
c.adw_tab_bar_set_view(tab_bar, tab_view);
c.adw_tab_bar_set_view(tab_bar, adw.tab_view);
if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0);
},
.gtk_notebook => {},
.gtk => {},
}
// The box is our main child
@ -386,6 +387,28 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_show(window);
}
/// 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 {
if (config.@"background-opacity" < 1) {
c.gtk_widget_remove_css_class(@ptrCast(self.window), "background");
} else {
c.gtk_widget_add_css_class(@ptrCast(self.window), "background");
}
if (self.wayland) |*wl| {
const blurred = switch (config.@"background-blur-radius") {
.false => false,
.true => true,
.radius => |v| v > 0,
};
try wl.setBlur(blurred);
}
}
/// 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,6 +446,8 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu));
if (self.wayland) |*wl| wl.deinit();
if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer);
}
@ -542,7 +567,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 +575,20 @@ 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.?);
if (self.app.wayland) |*wl| {
self.wayland = wayland.SurfaceState.init(v, wl);
}
self.syncAppearance(&self.app.config) catch |err| {
log.err("failed to initialize appearance={}", .{err});
};
return true;
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
// sends an undefined value.
fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
@ -570,7 +609,7 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT
const alloc = self.app.core_app.alloc;
const surface = self.actionSurface();
const tab = Tab.create(alloc, self, surface) catch return null;
return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box)));
return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box)));
}
fn adwTabOverviewOpen(
@ -894,8 +933,6 @@ fn gtkActionCopy(
log.warn("error performing binding action error={}", .{err});
return;
};
self.sendToast("Copied to clipboard");
}
fn gtkActionPaste(

View File

@ -14,6 +14,9 @@ pub const c = @cImport({
// 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

@ -143,6 +143,8 @@ 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
try self.imgui_widget.init();

View File

@ -129,7 +129,7 @@ pub fn eventMods(
// 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
// presssed (i.e. left control).
// pressed (i.e. left control).
break :mods translateMods(c.gdk_device_get_modifier_state(device));
};

View File

@ -4,161 +4,76 @@ const c = @import("c.zig").c;
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const NotebookAdw = @import("notebook_adw.zig").NotebookAdw;
const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk;
const adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
pub const Notebook = union(enum) {
adw_tab_view: *AdwTabView,
gtk_notebook: *c.GtkNotebook,
adw: NotebookAdw,
gtk: NotebookGtk,
pub fn create(window: *Window) Notebook {
pub fn init(self: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", self);
const app = window.app;
if (adwaita.enabled(&app.config)) return initAdw(window);
return initGtk(window);
if (adwaita.enabled(&app.config)) return NotebookAdw.init(self);
return NotebookGtk.init(self);
}
fn initGtk(window: *Window) Notebook {
const app = window.app;
// Create a notebook to hold our tabs.
const notebook_widget: *c.GtkWidget = c.gtk_notebook_new();
const notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") {
.top, .hidden => c.GTK_POS_TOP,
.bottom => c.GTK_POS_BOTTOM,
.left => c.GTK_POS_LEFT,
.right => c.GTK_POS_RIGHT,
};
c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos);
c.gtk_notebook_set_scrollable(notebook, 1);
c.gtk_notebook_set_show_tabs(notebook, 0);
c.gtk_notebook_set_show_border(notebook, 0);
// This enables all Ghostty terminal tabs to be exchanged across windows.
c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs");
// This is important so the notebook expands to fit available space.
// Otherwise, it will be zero/zero in the box below.
c.gtk_widget_set_vexpand(notebook_widget, 1);
c.gtk_widget_set_hexpand(notebook_widget, 1);
// Remove the background from the stack widget
const stack = c.gtk_widget_get_last_child(notebook_widget);
c.gtk_widget_add_css_class(stack, "transparent");
// All of our events
_ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(&gtkPageAdded), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(&gtkPageRemoved), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(&gtkSwitchPage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(&gtkNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT);
return .{ .gtk_notebook = notebook };
}
fn initAdw(window: *Window) Notebook {
const app = window.app;
assert(adwaita.enabled(&app.config));
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
}
_ = 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, "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);
return .{ .adw_tab_view = tab_view };
}
pub fn asWidget(self: Notebook) *c.GtkWidget {
return switch (self) {
.adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)),
.gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)),
pub fn asWidget(self: *Notebook) *c.GtkWidget {
return switch (self.*) {
.adw => |*adw| adw.asWidget(),
.gtk => |*gtk| gtk.asWidget(),
};
}
pub fn nPages(self: Notebook) c_int {
return switch (self) {
.gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook),
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0))
c.adw_tab_view_get_n_pages(tab_view)
else
unreachable,
pub fn nPages(self: *Notebook) c_int {
return switch (self.*) {
.adw => |*adw| adw.nPages(),
.gtk => |*gtk| gtk.nPages(),
};
}
/// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages.
fn currentPage(self: Notebook) ?c_int {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
return c.adw_tab_view_get_page_position(tab_view, page);
},
.gtk_notebook => |notebook| {
const current = c.gtk_notebook_get_current_page(notebook);
return if (current == -1) null else current;
},
}
fn currentPage(self: *Notebook) ?c_int {
return switch (self.*) {
.adw => |*adw| adw.currentPage(),
.gtk => |*gtk| gtk.currentPage(),
};
}
/// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: Notebook) ?*Tab {
const child = switch (self) {
.adw_tab_view => |tab_view| child: {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null;
const child = c.adw_tab_page_get_child(page);
break :child child;
},
.gtk_notebook => |notebook| child: {
const page = self.currentPage() orelse return null;
break :child c.gtk_notebook_get_nth_page(notebook, page);
},
};
return @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
));
}
pub fn gotoNthTab(self: Notebook, position: c_int) void {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position);
c.adw_tab_view_set_selected_page(tab_view, page_to_select);
},
.gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position),
}
}
pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int {
return switch (self) {
.adw_tab_view => |tab_view| page_idx: {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null;
break :page_idx c.adw_tab_view_get_page_position(tab_view, page);
},
.gtk_notebook => |notebook| page_idx: {
const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null;
break :page_idx getNotebookPageIndex(page);
},
pub fn currentTab(self: *Notebook) ?*Tab {
return switch (self.*) {
.adw => |*adw| adw.currentTab(),
.gtk => |*gtk| gtk.currentTab(),
};
}
pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void {
pub fn gotoNthTab(self: *Notebook, position: c_int) void {
switch (self.*) {
.adw => |*adw| adw.gotoNthTab(position),
.gtk => |*gtk| gtk.gotoNthTab(position),
}
}
pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int {
return switch (self.*) {
.adw => |*adw| adw.getTabPosition(tab),
.gtk => |*gtk| gtk.getTabPosition(tab),
};
}
pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void {
const page_idx = self.getTabPosition(tab) orelse return;
// The next index is the previous or we wrap around.
@ -173,7 +88,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx);
}
pub fn gotoNextTab(self: Notebook, tab: *Tab) void {
pub fn gotoNextTab(self: *Notebook, tab: *Tab) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
@ -183,7 +98,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx);
}
pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void {
pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
@ -199,42 +114,28 @@ pub const Notebook = union(enum) {
self.reorderPage(tab, new_position);
}
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void {
switch (self) {
.gtk_notebook => |notebook| {
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position);
},
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
_ = c.adw_tab_view_reorder_page(tab_view, page, position);
},
pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void {
switch (self.*) {
.adw => |*adw| adw.reorderPage(tab, position),
.gtk => |*gtk| gtk.reorderPage(tab, position),
}
}
pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_title(page, title.ptr);
},
.gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr),
pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self.*) {
.adw => |*adw| adw.setTabLabel(tab, title),
.gtk => |*gtk| gtk.setTabLabel(tab, title),
}
}
pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_tooltip(page, tooltip.ptr);
},
.gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr),
pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self.*) {
.adw => |*adw| adw.setTabTooltip(tab, tooltip),
.gtk => |*gtk| gtk.setTabTooltip(tab, tooltip),
}
}
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int {
fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int {
const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") {
.current => if (self.currentPage()) |page| page + 1 else numPages,
@ -243,249 +144,23 @@ pub const Notebook = union(enum) {
}
/// Adds a new tab with the given title to the notebook.
pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void {
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab));
c.adw_tab_page_set_title(page, title.ptr);
// Switch to the new tab
c.adw_tab_view_set_selected_page(tab_view, page);
},
.gtk_notebook => |notebook| {
// Build the tab label
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
const label_text_widget = c.gtk_label_new(title.ptr);
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
c.gtk_box_append(label_box, label_text_widget);
tab.label_text = label_text;
const window = tab.window;
if (window.app.config.@"gtk-wide-tabs") {
c.gtk_widget_set_hexpand(label_box_widget, 1);
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
c.gtk_widget_set_hexpand(label_text_widget, 1);
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
// This ensures that tabs are always equal width. If they're too
// long, they'll be truncated with an ellipsis.
c.gtk_label_set_max_width_chars(label_text, 1);
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
// We need to set a minimum width so that at a certain point
// the notebook will have an arrow button rather than shrinking tabs
// to an unreadably small size.
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
}
// Build the close button for the tab
const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic");
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
c.gtk_button_set_has_frame(label_close, 0);
c.gtk_box_append(label_box, label_close_widget);
const page_idx = c.gtk_notebook_insert_page(
notebook,
box_widget,
label_box_widget,
self.newTabInsertPosition(tab),
);
// Clicks
const gesture_tab_click = c.gtk_gesture_click_new();
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);
// Tab settings
c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1);
if (self.nPages() > 1) {
c.gtk_notebook_set_show_tabs(notebook, 1);
}
// Switch to the new tab
c.gtk_notebook_set_current_page(notebook, page_idx);
},
pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
const position = self.newTabInsertPosition(tab);
switch (self.*) {
.adw => |*adw| adw.addTab(tab, position, title),
.gtk => |*gtk| gtk.addTab(tab, position, title),
}
}
pub fn closeTab(self: Notebook, tab: *Tab) void {
const window = tab.window;
switch (self) {
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return;
c.adw_tab_view_close_page(tab_view, page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
// 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
// warning from GTK, but I don't know any other workaround.
// Note: I'm not actually sure if 1.4.0 contains the fix,
// I just know that 1.3.x is broken and 1.5.1 is fixed.
// If we know that 1.4.0 is fixed, we can change this.
if (!adwaita.versionAtLeast(1, 4, 0)) {
c.g_object_unref(tab.box);
pub fn closeTab(self: *Notebook, tab: *Tab) void {
switch (self.*) {
.adw => |*adw| adw.closeTab(tab),
.gtk => |*gtk| gtk.closeTab(tab),
}
c.gtk_window_destroy(window.window);
}
},
.gtk_notebook => |notebook| {
const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return;
// Find page and tab which we're closing
const page_idx = getNotebookPageIndex(page);
// Remove the page. This will destroy the GTK widgets in the page which
// will trigger Tab cleanup. The `tab` variable is therefore unusable past that point.
c.gtk_notebook_remove_page(notebook, page_idx);
const remaining = self.nPages();
switch (remaining) {
// If we have no more tabs we close the window
0 => c.gtk_window_destroy(tab.window.window),
// If we have one more tab we hide the tab bar
1 => c.gtk_notebook_set_show_tabs(notebook, 0),
else => {},
}
// If we have remaining tabs, we need to make sure we grab focus.
if (remaining > 0) window.focusCurrentTab();
},
}
}
fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int {
var value: c.GValue = std.mem.zeroes(c.GValue);
defer c.g_value_unset(&value);
_ = c.g_value_init(&value, c.G_TYPE_INT);
c.g_object_get_property(
@ptrCast(@alignCast(page)),
"position",
&value,
);
return c.g_value_get_int(&value);
}
};
fn gtkPageRemoved(
_: *c.GtkNotebook,
_: *c.GtkWidget,
_: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud.?));
const notebook: *c.GtkNotebook = self.notebook.gtk_notebook;
// Hide the tab bar if we only have one tab after removal
const remaining = c.gtk_notebook_get_n_pages(notebook);
if (remaining == 1) {
c.gtk_notebook_set_show_tabs(notebook, 0);
}
}
fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void {
_ = position;
_ = tab_view;
const self: *Window = @ptrCast(@alignCast(ud.?));
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));
tab.window = self;
self.focusCurrentTab();
}
fn gtkPageAdded(
notebook: *c.GtkNotebook,
_: *c.GtkWidget,
page_idx: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud.?));
// The added page can come from another window with drag and drop, thus we migrate the tab
// window to be self.
const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx));
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
));
tab.window = self;
// Whenever a new page is added, we always grab focus of the
// currently selected page. This was added specifically so that when
// we drag a tab out to create a new window ("create-window" event)
// we grab focus in the new window. Without this, the terminal didn't
// have focus.
self.focusCurrentTab();
}
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);
}
fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_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);
}
fn adwTabViewCreateWindow(
_: *AdwTabView,
ud: ?*anyopaque,
) callconv(.C) ?*AdwTabView {
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
return window.notebook.adw_tab_view;
}
fn gtkNotebookCreateWindow(
_: *c.GtkNotebook,
page: *c.GtkWidget,
ud: ?*anyopaque,
) callconv(.C) ?*c.GtkNotebook {
// The tab for the page is stored in the widget data.
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
));
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
// And add it to the new window.
tab.window = window;
return window.notebook.gtk_notebook;
}
fn createWindow(currentWindow: *Window) !*Window {
pub fn createWindow(currentWindow: *Window) !*Window {
const alloc = currentWindow.app.core_app.alloc;
const app = currentWindow.app;

View File

@ -0,0 +1,163 @@
const std = @import("std");
const assert = std.debug.assert;
const c = @import("c.zig").c;
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const Notebook = @import("notebook.zig").Notebook;
const createWindow = @import("notebook.zig").createWindow;
const adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque;
pub const NotebookAdw = struct {
/// the tab view
tab_view: *AdwTabView,
pub fn init(notebook: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", notebook);
const app = window.app;
assert(adwaita.enabled(&app.config));
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook");
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
}
notebook.* = .{
.adw = .{
.tab_view = tab_view,
},
};
_ = 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, "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);
}
pub fn asWidget(self: *NotebookAdw) *c.GtkWidget {
return @ptrCast(@alignCast(self.tab_view));
}
pub fn nPages(self: *NotebookAdw) c_int {
if (comptime adwaita.versionAtLeast(0, 0, 0))
return c.adw_tab_view_get_n_pages(self.tab_view)
else
unreachable;
}
/// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages.
pub fn currentPage(self: *NotebookAdw) ?c_int {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null;
return c.adw_tab_view_get_page_position(self.tab_view, page);
}
/// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: *NotebookAdw) ?*Tab {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null;
const child = c.adw_tab_page_get_child(page);
return @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
));
}
pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position);
c.adw_tab_view_set_selected_page(self.tab_view, page_to_select);
}
pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null;
return c.adw_tab_view_get_page_position(self.tab_view, page);
}
pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
_ = c.adw_tab_view_reorder_page(self.tab_view, page, position);
}
pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_title(page, title.ptr);
}
pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_tooltip(page, tooltip.ptr);
}
pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
const page = c.adw_tab_view_insert(self.tab_view, box_widget, position);
c.adw_tab_page_set_title(page, title.ptr);
c.adw_tab_view_set_selected_page(self.tab_view, page);
}
pub fn closeTab(self: *NotebookAdw, tab: *Tab) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
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) {
// 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
// warning from GTK, but I don't know any other workaround.
// Note: I'm not actually sure if 1.4.0 contains the fix,
// I just know that 1.3.x is broken and 1.5.1 is fixed.
// If we know that 1.4.0 is fixed, we can change this.
if (!adwaita.versionAtLeast(1, 4, 0)) {
c.g_object_unref(tab.box);
}
c.gtk_window_destroy(tab.window.window);
}
}
};
fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
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));
tab.window = window;
window.focusCurrentTab();
}
fn adwTabViewCreateWindow(
_: *AdwTabView,
ud: ?*anyopaque,
) callconv(.C) ?*AdwTabView {
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
return window.notebook.adw.tab_view;
}
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);
}

View File

@ -0,0 +1,285 @@
const std = @import("std");
const assert = std.debug.assert;
const c = @import("c.zig").c;
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
const Notebook = @import("notebook.zig").Notebook;
const createWindow = @import("notebook.zig").createWindow;
const log = std.log.scoped(.gtk);
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
pub const NotebookGtk = struct {
notebook: *c.GtkNotebook,
pub fn init(notebook: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", notebook);
const app = window.app;
// Create a notebook to hold our tabs.
const notebook_widget: *c.GtkWidget = c.gtk_notebook_new();
c.gtk_widget_add_css_class(notebook_widget, "notebook");
const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") {
.top, .hidden => c.GTK_POS_TOP,
.bottom => c.GTK_POS_BOTTOM,
.left => c.GTK_POS_LEFT,
.right => c.GTK_POS_RIGHT,
};
c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos);
c.gtk_notebook_set_scrollable(gtk_notebook, 1);
c.gtk_notebook_set_show_tabs(gtk_notebook, 0);
c.gtk_notebook_set_show_border(gtk_notebook, 0);
// This enables all Ghostty terminal tabs to be exchanged across windows.
c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs");
// This is important so the notebook expands to fit available space.
// Otherwise, it will be zero/zero in the box below.
c.gtk_widget_set_vexpand(notebook_widget, 1);
c.gtk_widget_set_hexpand(notebook_widget, 1);
// Remove the background from the stack widget
const stack = c.gtk_widget_get_last_child(notebook_widget);
c.gtk_widget_add_css_class(stack, "transparent");
notebook.* = .{
.gtk = .{
.notebook = gtk_notebook,
},
};
// All of our events
_ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(&gtkPageAdded), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(&gtkPageRemoved), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(&gtkSwitchPage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(&gtkNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT);
}
/// return the underlying widget as a generic GtkWidget
pub fn asWidget(self: *NotebookGtk) *c.GtkWidget {
return @ptrCast(@alignCast(self.notebook));
}
/// returns the number of pages in the notebook
pub fn nPages(self: *NotebookGtk) c_int {
return c.gtk_notebook_get_n_pages(self.notebook);
}
/// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages.
pub fn currentPage(self: *NotebookGtk) ?c_int {
const current = c.gtk_notebook_get_current_page(self.notebook);
return if (current == -1) null else current;
}
/// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: *NotebookGtk) ?*Tab {
log.warn("currentTab", .{});
const page = self.currentPage() orelse return null;
const child = c.gtk_notebook_get_nth_page(self.notebook, page);
return @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null,
));
}
/// focus the nth tab
pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void {
c.gtk_notebook_set_current_page(self.notebook, position);
}
/// get the position of the current tab
pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int {
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null;
return getNotebookPageIndex(page);
}
pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void {
c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position);
}
pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void {
c.gtk_label_set_text(tab.label_text, title.ptr);
}
pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void {
c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr);
}
/// Adds a new tab with the given title to the notebook.
pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void {
const box_widget: *c.GtkWidget = @ptrCast(tab.box);
// Build the tab label
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
const label_text_widget = c.gtk_label_new(title.ptr);
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
c.gtk_box_append(label_box, label_text_widget);
tab.label_text = label_text;
const window = tab.window;
if (window.app.config.@"gtk-wide-tabs") {
c.gtk_widget_set_hexpand(label_box_widget, 1);
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
c.gtk_widget_set_hexpand(label_text_widget, 1);
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
// This ensures that tabs are always equal width. If they're too
// long, they'll be truncated with an ellipsis.
c.gtk_label_set_max_width_chars(label_text, 1);
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
// We need to set a minimum width so that at a certain point
// the notebook will have an arrow button rather than shrinking tabs
// to an unreadably small size.
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
}
// Build the close button for the tab
const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic");
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
c.gtk_button_set_has_frame(label_close, 0);
c.gtk_box_append(label_box, label_close_widget);
const page_idx = c.gtk_notebook_insert_page(
self.notebook,
box_widget,
label_box_widget,
position,
);
// Clicks
const gesture_tab_click = c.gtk_gesture_click_new();
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);
// Tab settings
c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1);
if (self.nPages() > 1) {
c.gtk_notebook_set_show_tabs(self.notebook, 1);
}
// Switch to the new tab
c.gtk_notebook_set_current_page(self.notebook, page_idx);
}
pub fn closeTab(self: *NotebookGtk, tab: *Tab) void {
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return;
// Find page and tab which we're closing
const page_idx = getNotebookPageIndex(page);
// Remove the page. This will destroy the GTK widgets in the page which
// will trigger Tab cleanup. The `tab` variable is therefore unusable past that point.
c.gtk_notebook_remove_page(self.notebook, page_idx);
const remaining = self.nPages();
switch (remaining) {
// If we have no more tabs we close the window
0 => c.gtk_window_destroy(tab.window.window),
// If we have one more tab we hide the tab bar
1 => c.gtk_notebook_set_show_tabs(self.notebook, 0),
else => {},
}
// If we have remaining tabs, we need to make sure we grab focus.
if (remaining > 0)
(self.currentTab() orelse return).window.focusCurrentTab();
}
};
fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int {
var value: c.GValue = std.mem.zeroes(c.GValue);
defer c.g_value_unset(&value);
_ = c.g_value_init(&value, c.G_TYPE_INT);
c.g_object_get_property(
@ptrCast(@alignCast(page)),
"position",
&value,
);
return c.g_value_get_int(&value);
}
fn gtkPageAdded(
notebook: *c.GtkNotebook,
_: *c.GtkWidget,
page_idx: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud.?));
// The added page can come from another window with drag and drop, thus we migrate the tab
// window to be self.
const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx));
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
));
tab.window = self;
// Whenever a new page is added, we always grab focus of the
// currently selected page. This was added specifically so that when
// we drag a tab out to create a new window ("create-window" event)
// we grab focus in the new window. Without this, the terminal didn't
// have focus.
self.focusCurrentTab();
}
fn gtkPageRemoved(
_: *c.GtkNotebook,
_: *c.GtkWidget,
_: c.guint,
ud: ?*anyopaque,
) callconv(.C) void {
log.warn("gtkPageRemoved", .{});
const window: *Window = @ptrCast(@alignCast(ud.?));
// Hide the tab bar if we only have one tab after removal
const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook);
if (remaining == 1) {
c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0);
}
}
fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const self = &window.notebook.gtk;
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);
}
fn gtkNotebookCreateWindow(
_: *c.GtkNotebook,
page: *c.GtkWidget,
ud: ?*anyopaque,
) callconv(.C) ?*c.GtkNotebook {
// The tab for the page is stored in the widget data.
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
));
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const newWindow = createWindow(currentWindow) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
// And add it to the new window.
tab.window = newWindow;
return newWindow.notebook.gtk.notebook;
}

View File

@ -2,7 +2,7 @@
background-color: transparent;
}
separator {
.terminal-window .notebook separator {
background-color: rgba(36, 36, 36, 1);
background-clip: content-box;
}

View File

@ -41,7 +41,7 @@ window.without-window-decoration-and-with-titlebar {
background-color: transparent;
}
separator {
.terminal-window .notebook separator {
background-color: rgba(250, 250, 250, 1);
background-clip: content-box;
}

125
src/apprt/gtk/wayland.zig Normal file
View File

@ -0,0 +1,125 @@
const std = @import("std");
const c = @import("c.zig").c;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const org = wayland.client.org;
const build_options = @import("build_options");
const log = std.log.scoped(.gtk_wayland);
/// Wayland state that contains application-wide Wayland objects (e.g. wl_display).
pub const AppState = struct {
display: *wl.Display,
blur_manager: ?*org.KdeKwinBlurManager = null,
pub fn init(display: ?*c.GdkDisplay) ?AppState {
if (comptime !build_options.wayland) return null;
// It should really never be null
const display_ = display orelse return null;
// Check if we're actually on Wayland
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(display_)),
c.gdk_wayland_display_get_type(),
) == 0)
return null;
const wl_display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(display_) orelse return null);
return .{
.display = wl_display,
};
}
pub fn register(self: *AppState) !void {
const registry = try self.display.getRegistry();
registry.setListener(*AppState, registryListener, self);
if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
log.debug("app wayland init={}", .{self});
}
};
/// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface).
pub const SurfaceState = struct {
app_state: *AppState,
surface: *wl.Surface,
/// A token that, when present, indicates that the window is blurred.
blur_token: ?*org.KdeKwinBlur = null,
pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState {
if (comptime !build_options.wayland) return null;
const surface = c.gtk_native_get_surface(@ptrCast(window)) orelse return null;
// Check if we're actually on Wayland
if (c.g_type_check_instance_is_a(
@ptrCast(@alignCast(surface)),
c.gdk_wayland_surface_get_type(),
) == 0)
return null;
const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return null);
return .{
.app_state = app_state,
.surface = wl_surface,
};
}
pub fn deinit(self: *SurfaceState) void {
if (self.blur_token) |blur| blur.release();
}
pub fn setBlur(self: *SurfaceState, blurred: bool) !void {
log.debug("setting blur={}", .{blurred});
const mgr = self.app_state.blur_manager orelse {
log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{});
return;
};
if (self.blur_token) |blur| {
// Only release token when transitioning from blurred -> not blurred
if (!blurred) {
mgr.unset(self.surface);
blur.release();
self.blur_token = null;
}
} else {
// Only acquire token when transitioning from not blurred -> blurred
if (blurred) {
const blur_token = try mgr.create(self.surface);
blur_token.commit();
self.blur_token = blur_token;
}
}
}
};
fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *AppState) void {
switch (event) {
.global => |global| {
log.debug("got global interface={s}", .{global.interface});
if (bindInterface(org.KdeKwinBlurManager, registry, global, 1)) |iface| {
state.blur_manager = iface;
return;
}
},
.global_remove => {},
}
}
fn bindInterface(comptime T: type, registry: *wl.Registry, global: anytype, version: u32) ?*T {
if (std.mem.orderZ(u8, global.interface, T.interface.name) == .eq) {
return registry.bind(global.name, T, version) catch |err| {
log.warn("encountered error={} while binding interface {s}", .{ err, global.interface });
return null;
};
} else {
return null;
}
}

View File

@ -23,6 +23,8 @@ pub const BuildConfig = struct {
flatpak: bool = false,
adwaita: bool = false,
x11: bool = false,
wayland: bool = false,
sentry: bool = true,
app_runtime: apprt.Runtime = .none,
renderer: rendererpkg.Impl = .opengl,
font_backend: font.Backend = .freetype,
@ -43,6 +45,8 @@ pub const BuildConfig = struct {
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(rendererpkg.Impl, "renderer", self.renderer);

View File

@ -533,7 +533,7 @@ fn parsePackedStruct(comptime T: type, v: []const u8) !T {
return result;
}
fn parseBool(v: []const u8) !bool {
pub fn parseBool(v: []const u8) !bool {
const t = &[_][]const u8{ "1", "t", "T", "true" };
const f = &[_][]const u8{ "0", "f", "F", "false" };

View File

@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.writeAll(
\\
\\Specify `+<action> --help` to see the help for a specific action,
\\where `<action>` is one of actions listed below.
\\where `<action>` is one of actions listed above.
\\
);

View File

@ -20,8 +20,9 @@ pub const Options = struct {
}
};
/// The `list-actions` command is used to list all the available keybind actions
/// for Ghostty.
/// The `list-actions` command is used to list all the available keybind
/// actions for Ghostty. These are distinct from the CLI Actions which can
/// be listed via `+help`
///
/// The `--docs` argument will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 {

View File

@ -11,6 +11,12 @@ const global_state = &@import("../global.zig").state;
const vaxis = @import("vaxis");
const zf = @import("zf");
// When the number of filtered themes is less than or equal to this threshold,
// the window position will be reset to 0 to show all results from the top.
// This ensures better visibility for small result sets while maintaining
// scroll position for larger lists.
const SMALL_LIST_THRESHOLD = 10;
pub const Options = struct {
/// If true, print the full path to the theme.
path: bool = false,
@ -323,9 +329,15 @@ const Preview = struct {
}
self.current, self.window = current: {
if (selected.len == 0) break :current .{ 0, 0 };
for (self.filtered.items, 0..) |index, i| {
if (std.mem.eql(u8, self.themes[index].theme, selected))
break :current .{ i, i -| relative };
if (std.mem.eql(u8, self.themes[index].theme, selected)) {
// Keep the relative position but ensure all search results are visible
const new_window = i -| relative;
// If the new window would hide some results at the top, adjust it
break :current .{ i, if (self.filtered.items.len <= SMALL_LIST_THRESHOLD) 0 else new_window };
}
}
break :current .{ 0, 0 };
};

View File

@ -3,6 +3,7 @@ const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const internal_os = @import("../os/main.zig");
const xev = @import("xev");
const renderer = @import("../renderer.zig");
const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void;
@ -37,6 +38,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.print(" - renderer : {}\n", .{renderer.Renderer});
try stdout.print(" - libxev : {}\n", .{xev.backend});
if (comptime build_config.app_runtime == .gtk) {
try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())});
try stdout.print(" - GTK version:\n", .{});
try stdout.print(" build : {d}.{d}.{d}\n", .{
gtk.GTK_MAJOR_VERSION,
@ -66,6 +68,14 @@ pub fn run(alloc: Allocator) !u8 {
} else {
try stdout.print(" - libX11 : disabled\n", .{});
}
// We say `libwayland` since it is possible to build Ghostty without
// Wayland integration but with Wayland-enabled GTK
if (comptime build_options.wayland) {
try stdout.print(" - libwayland : enabled\n", .{});
} else {
try stdout.print(" - libwayland : disabled\n", .{});
}
}
return 0;
}

View File

@ -147,23 +147,28 @@ const c = @cImport({
/// By default, synthetic styles are enabled.
@"font-synthetic-style": FontSyntheticStyle = .{},
/// Apply a font feature. This can be repeated multiple times to enable multiple
/// font features. You can NOT set multiple font features with a single value
/// (yet).
/// Apply a font feature. To enable multiple font features you can repeat
/// this multiple times or use a comma-separated list of feature settings.
///
/// The syntax for feature settings is as follows, where `feat` is a feature:
///
/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`.
/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`.
/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`.
/// * Feature names may be wrapped in quotes, meaning this config should be
/// syntactically compatible with the `font-feature-settings` CSS property.
///
/// The syntax is fairly loose, but invalid settings will be silently ignored.
///
/// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces.
///
/// A valid value is the name of a feature. Prefix the feature with a `-` to
/// explicitly disable it. Example: `ss20` or `-ss20`.
///
/// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features
/// your font has and what they do, use a font inspection tool such as
/// [fontdrop.info](https://fontdrop.info).
///
/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as
/// separate repetitive entries in your config).
/// To generally disable most ligatures, use `-calt, -liga, -dlig`.
@"font-feature": RepeatableString = .{},
/// Font size in points. This value can be a non-integer and the nearest integer
@ -177,6 +182,10 @@ const c = @cImport({
/// depending on your `window-inherit-font-size` setting. If that setting is
/// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window.
///
/// On Linux with GTK, font size is scaled according to both display-wide and
/// text-specific scaling factors, which are often managed by your desktop
/// environment (e.g. the GNOME display scale and large text settings).
@"font-size": f32 = switch (builtin.os.tag) {
// On macOS we default a little bigger since this tends to look better. This
// is purely subjective but this is easy to modify.
@ -225,10 +234,20 @@ const c = @cImport({
/// i.e. new windows, tabs, etc.
@"font-codepoint-map": RepeatableCodepointMap = .{},
/// Draw fonts with a thicker stroke, if supported. This is only supported
/// currently on macOS.
/// Draw fonts with a thicker stroke, if supported.
/// This is currently only supported on macOS.
@"font-thicken": bool = false,
/// Strength of thickening when `font-thicken` is enabled.
///
/// Valid values are integers between `0` and `255`. `0` does not correspond to
/// *no* thickening, rather it corresponds to the lightest available thickening.
///
/// Has no effect when `font-thicken` is set to `false`.
///
/// This is currently only supported on macOS.
@"font-thicken-strength": u8 = 255,
/// All of the configurations behavior adjust various metrics determined by the
/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%,
/// etc.). In each case, the values represent the amount to change the original
@ -320,7 +339,7 @@ const c = @cImport({
/// FreeType load flags to enable. The format of this is a list of flags to
/// enable separated by commas. If you prefix a flag with `no-` then it is
/// disabled. If you omit a flag, it's default value is used, so you must
/// disabled. If you omit a flag, its default value is used, so you must
/// explicitly disable flags you don't want. You can also use `true` or `false`
/// to turn all flags on or off.
///
@ -398,14 +417,17 @@ const c = @cImport({
theme: ?Theme = null,
/// Background color for the window.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
/// Foreground color for the window.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The foreground and background color for selection. If this is not set, then
/// the selection color is just the inverted window background and foreground
/// (note: not to be confused with the cell bg/fg).
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"selection-foreground": ?Color = null,
@"selection-background": ?Color = null,
@ -431,15 +453,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
@"minimum-contrast": f64 = 1,
/// Color palette for the 256 color form that many terminal applications use.
/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB
/// color code such as `#AABBCC`.
/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB
/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color.
///
/// For definitions on all the codes [see this cheat
/// sheet](https://www.ditig.com/256-colors-cheat-sheet).
/// The palette index can be in decimal, binary, octal, or hexadecimal.
/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal,
/// and `0x` for hexadecimal.
///
/// For definitions on the color indices and what they canonically map to,
/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet).
palette: Palette = .{},
/// The color of the cursor. If this is not set, a default will be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-color": ?Color = null,
/// Swap the foreground and background colors of the cell under the cursor. This
@ -493,6 +520,7 @@ palette: Palette = .{},
/// The color of the text under the cursor. If this is not set, a default will
/// be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-text": ?Color = null,
/// Enables the ability to move the cursor at prompts by using `alt+click` on
@ -548,7 +576,7 @@ palette: Palette = .{},
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value.
///
/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls
/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls
/// double the default amount. A value of "0.5" scrolls half the default amount.
/// Et cetera.
@"mouse-scroll-multiplier": f64 = 1.0,
@ -560,15 +588,42 @@ palette: Palette = .{},
/// On macOS, background opacity is disabled when the terminal enters native
/// fullscreen. This is because the background becomes gray and it can cause
/// widgets to show through which isn't generally desirable.
///
/// On macOS, changing this configuration requires restarting Ghostty completely.
@"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity
/// is less than 1. The value is the blur radius to apply. A value of 20
/// is reasonable for a good looking blur. Higher values will cause strange
/// rendering issues as well as performance issues.
/// Whether to blur the background when `background-opacity` is less than 1.
///
/// This is only supported on macOS.
@"background-blur-radius": u8 = 0,
/// Valid values are:
///
/// * a nonnegative integer specifying the *blur intensity*
/// * `false`, equivalent to a blur intensity of 0
/// * `true`, equivalent to the default blur intensity of 20, which is
/// reasonable for a good looking blur. Higher blur intensities may
/// cause strange rendering and performance issues.
///
/// Supported on macOS and on some Linux desktop environments, including:
///
/// * KDE Plasma (Wayland only)
///
/// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting
/// this setting to either `true` or any positive blur intensity value would
/// achieve the same effect. The reason is that KWin, the window compositor
/// powering Plasma, only has one global blur setting and does not allow
/// applications to specify individual blur settings.
///
/// To configure KWin's global blur setting, open System Settings and go to
/// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the
/// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left.
/// Then click on the "Configure" button and there will be two sliders that
/// allow you to set background blur and noise intensities for all apps,
/// including Ghostty.
///
/// All other Linux desktop environments are as of now unsupported. Users may
/// need to set environment-specific settings and/or install third-party plugins
/// in order to support background blur, as there isn't a unified interface for
/// doing so.
@"background-blur-radius": BackgroundBlur = .false,
/// The opacity level (opposite of transparency) of an unfocused split.
/// Unfocused splits by default are slightly faded out to make it easier to see
@ -586,8 +641,14 @@ palette: Palette = .{},
/// that rectangle and can be used to carefully control the dimming effect.
///
/// This will default to the background color.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"unfocused-split-fill": ?Color = null,
/// The color of the split divider. If this is not set, a default will be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"split-divider-color": ?Color = null,
/// The command to run, usually a shell. If this is not an absolute path, it'll
/// be looked up in the `PATH`. If this is not set, a default will be looked up
/// from your system. The rules for the default lookup are:
@ -724,7 +785,7 @@ fullscreen: bool = false,
/// This configuration can be reloaded at runtime. If it is set, the title
/// will update for all windows. If it is unset, the next title change escape
/// sequence will be honored but previous changes will not retroactively
/// be set. This latter case may require you restart programs such as neovim
/// be set. This latter case may require you to restart programs such as Neovim
/// to get the new title.
title: ?[:0]const u8 = null,
@ -907,6 +968,15 @@ class: ?[:0]const u8 = null,
/// Since they are not associated with a specific terminal surface,
/// they're never encoded.
///
/// * `performable:` - Only consume the input if the action is able to be
/// performed. For example, the `copy_to_clipboard` action will only
/// consume the input if there is a selection to copy. If there is no
/// selection, Ghostty behaves as if the keybind was not set. This has
/// no effect with `global:` or `all:`-prefixed keybinds. For key
/// sequences, this will reset the sequence if the action is not
/// performable (acting identically to not having a keybind set at
/// all).
///
/// Keybind triggers are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
/// set later will overwrite the keybind set earlier. In this case, the
@ -1042,7 +1112,10 @@ keybind: Keybinds = .{},
/// The font that will be used for the application's window and tab titles.
///
/// This is currently only supported on macOS.
/// If this setting is left unset, the system default font will be used.
///
/// Note: any font available on the system may be used, this font is not
/// required to be a fixed-width font.
@"window-title-font-family": ?[:0]const u8 = null,
/// The theme to use for the windows. Valid values:
@ -1104,6 +1177,32 @@ keybind: Keybinds = .{},
@"window-height": u32 = 0,
@"window-width": u32 = 0,
/// The starting window position. This position is in pixels and is relative
/// to the top-left corner of the primary monitor. Both values must be set to take
/// effect. If only one value is set, it is ignored.
///
/// Note that the window manager may put limits on the position or override
/// the position. For example, a tiling window manager may force the window
/// to be a certain position to fit within the grid. There is nothing Ghostty
/// will do about this, but it will make an effort.
///
/// Also note that negative values are also up to the operating system and
/// window manager. Some window managers may not allow windows to be placed
/// off-screen.
///
/// Invalid positions are runtime-specific, but generally the positions are
/// clamped to the nearest valid position.
///
/// On macOS, the window position is relative to the top-left corner of
/// the visible screen area. This means that if the menu bar is visible, the
/// window will be placed below the menu bar.
///
/// Note: this is only supported on macOS and Linux GLFW builds. The GTK
/// runtime does not support setting the window position (this is a limitation
/// of GTK 4.0).
@"window-position-x": ?i16 = null,
@"window-position-y": ?i16 = null,
/// Whether to enable saving and restoring window state. Window state includes
/// their position, size, tabs, splits, etc. Some window state requires shell
/// integration, such as preserving working directories. See `shell-integration`
@ -1152,11 +1251,15 @@ keybind: Keybinds = .{},
/// Background color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-background": ?Color = null,
/// Foreground color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-foreground": ?Color = null,
/// This controls when resize overlays are shown. Resize overlays are a
@ -1772,21 +1875,19 @@ keybind: Keybinds = .{},
/// The color of the ghost in the macOS app icon.
///
/// The format of the color is the same as the `background` configuration;
/// see that for more information.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
///
/// This only has an effect when `macos-icon` is set to `custom-style`.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"macos-icon-ghost-color": ?Color = null,
/// The color of the screen in the macOS app icon.
///
/// The screen is a gradient so you can specify multiple colors that
/// make up the gradient. Colors should be separated by commas. The
/// format of the color is the same as the `background` configuration;
/// see that for more information.
/// make up the gradient. Comma-separated colors may be specified as
/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors.
///
/// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`.
@ -1905,6 +2006,29 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised,
/// Control the toasts that Ghostty shows. Toasts are small notifications
/// that appear overlaid on top of the terminal window. They are used to
/// show information that is not critical but may be important.
///
/// Possible toasts are:
///
/// - `clipboard-copy` (default: true) - Show a toast when text is copied
/// to the clipboard.
///
/// To specify a toast to enable, specify the name of the toast. To specify
/// a toast to disable, prefix the name with `no-`. For example, to disable
/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`.
/// To enable the clipboard-copy toast, set this configuration to
/// `clipboard-copy`.
///
/// Multiple toasts can be enabled or disabled by separating them with a comma.
///
/// A value of "false" will disable all toasts. A value of "true" will
/// enable all toasts.
///
/// This configuration only applies to GTK with Adwaita enabled.
@"adw-toast": AdwToast = .{},
/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs
/// are the new typical Gnome style where tabs fill their available space.
/// If you set this to `false` then tabs will only take up space they need,
@ -1925,6 +2049,15 @@ keybind: Keybinds = .{},
/// Adwaita support.
@"gtk-adwaita": bool = true,
/// Custom CSS files to be loaded.
///
/// This configuration can be repeated multiple times to load multiple files.
/// Prepend a ? character to the file path to suppress errors if the file does
/// not exist. If you want to include a file that begins with a literal ?
/// character, surround the file path in double quotes (").
/// The file size limit for a single stylesheet is 5MiB.
@"gtk-custom-css": RepeatablePath = .{},
/// If `true` (default), applications running in the terminal can show desktop
/// notifications using certain escape sequences such as OSC 9 or OSC 777.
@"desktop-notifications": bool = true,
@ -1963,10 +2096,11 @@ term: []const u8 = "xterm-ghostty",
/// * `download` - Check for updates, automatically download the update,
/// notify the user, but do not automatically install the update.
///
/// The default value is `check`.
/// If unset, we defer to Sparkle's default behavior, which respects the
/// preference stored in the standard user defaults (`defaults(1)`).
///
/// Changing this value at runtime works after a small delay.
@"auto-update": AutoUpdate = .check,
@"auto-update": ?AutoUpdate = null,
/// The release channel to use for auto-updates.
///
@ -2066,6 +2200,25 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
);
{
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
// alt keybinding for Copy and shift+ins is an alt keybinding for Paste
//
// The order of these blocks is important. The *last* added keybind for a given action is
// what will display in the menu. We want the more typical keybinds after this block to be
// the standard
if (!builtin.target.isDarwin()) {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } },
.{ .copy_to_clipboard = {} },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } },
.{ .paste_from_clipboard = {} },
);
}
// On macOS we default to super but Linux ctrl+shift since
// ctrl+c is to kill the process.
const mods: inputpkg.Mods = if (builtin.target.isDarwin())
@ -2124,45 +2277,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
);
// Expand Selection
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .shift = true } },
.{ .adjust_selection = .left },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .shift = true } },
.{ .adjust_selection = .right },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .up },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .down },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_up },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_down },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .home }, .mods = .{ .shift = true } },
.{ .adjust_selection = .home },
.{ .performable = true },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .end }, .mods = .{ .shift = true } },
.{ .adjust_selection = .end },
.{ .performable = true },
);
// Tabs common to all platforms
@ -2247,12 +2408,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .top },
.{ .goto_split = .up },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .bottom },
.{ .goto_split = .down },
);
try result.keybind.set.put(
alloc,
@ -2412,10 +2573,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .q }, .mods = .{ .super = true } },
.{ .quit = {} },
);
try result.keybind.set.put(
try result.keybind.set.putFlags(
alloc,
.{ .key = .{ .translated = .k }, .mods = .{ .super = true } },
.{ .clear_screen = {} },
.{ .performable = true },
);
try result.keybind.set.put(
alloc,
@ -2516,12 +2678,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .top },
.{ .goto_split = .up },
);
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .bottom },
.{ .goto_split = .down },
);
try result.keybind.set.put(
alloc,
@ -2695,6 +2857,9 @@ pub fn loadOptionalFile(
fn writeConfigTemplate(path: []const u8) !void {
log.info("creating template config file: path={s}", .{path});
if (std.fs.path.dirname(path)) |dir_path| {
try std.fs.makeDirAbsolute(dir_path);
}
const file = try std.fs.createFileAbsolute(path, .{});
defer file.close();
try std.fmt.format(
@ -2798,7 +2963,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
self.@"config-default-files" = true;
// Keep track of the replay steps up to this point so we
// can replay if we are disgarding the default files.
// can replay if we are discarding the default files.
const replay_len_start = self._replay_steps.items.len;
// Keep track of font families because if they are set from the CLI
@ -4036,7 +4201,7 @@ pub const Palette = struct {
const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
return error.InvalidValue;
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10);
const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0);
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
}
@ -4076,6 +4241,28 @@ pub const Palette = struct {
try testing.expect(p.value[0].b == 0xCC);
}
test "parseCLI base" {
const testing = std.testing;
var p: Self = .{};
try p.parseCLI("0b1=#014589");
try p.parseCLI("0o7=#234567");
try p.parseCLI("0xF=#ABCDEF");
try testing.expect(p.value[0b1].r == 0x01);
try testing.expect(p.value[0b1].g == 0x45);
try testing.expect(p.value[0b1].b == 0x89);
try testing.expect(p.value[0o7].r == 0x23);
try testing.expect(p.value[0o7].g == 0x45);
try testing.expect(p.value[0o7].b == 0x67);
try testing.expect(p.value[0xF].r == 0xAB);
try testing.expect(p.value[0xF].g == 0xCD);
try testing.expect(p.value[0xF].b == 0xEF);
}
test "parseCLI overflow" {
const testing = std.testing;
@ -4340,6 +4527,45 @@ pub const RepeatablePath = struct {
// If it isn't absolute, we need to make it absolute relative
// to the base.
var buf: [std.fs.max_path_bytes]u8 = undefined;
// Check if the path starts with a tilde and expand it to the
// home directory on Linux/macOS. We explicitly look for "~/"
// because we don't support alternate users such as "~alice/"
if (std.mem.startsWith(u8, path, "~/")) expand: {
// Windows isn't supported yet
if (comptime builtin.os.tag == .windows) break :expand;
const expanded: []const u8 = internal_os.expandHome(
path,
&buf,
) catch |err| {
try diags.append(alloc, .{
.message = try std.fmt.allocPrintZ(
alloc,
"error expanding home directory for path {s}: {}",
.{ path, err },
),
});
// Blank this path so that we don't attempt to resolve it
// again
self.value.items[i] = .{ .required = "" };
continue;
};
log.debug(
"expanding file path from home directory: path={s}",
.{expanded},
);
switch (self.value.items[i]) {
.optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded),
}
continue;
}
const abs = dir.realpath(path, &buf) catch |err| abs: {
if (err == error.FileNotFound) {
// The file doesn't exist. Try to resolve the relative path
@ -5348,6 +5574,11 @@ pub const AdwToolbarStyle = enum {
@"raised-border",
};
/// See adw-toast
pub const AdwToast = packed struct {
@"clipboard-copy": bool = true,
};
/// See mouse-shift-capture
pub const MouseShiftCapture = enum {
false,
@ -5441,6 +5672,70 @@ pub const AutoUpdate = enum {
download,
};
/// See background-blur-radius
pub const BackgroundBlur = union(enum) {
false,
true,
radius: u8,
pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void {
const input_ = input orelse {
// Emulate behavior for bools
self.* = .true;
return;
};
self.* = if (cli.args.parseBool(input_)) |b|
if (b) .true else .false
else |_|
.{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
}
pub fn cval(self: BackgroundBlur) u8 {
return switch (self) {
.false => 0,
.true => 20,
.radius => |v| v,
};
}
pub fn formatEntry(
self: BackgroundBlur,
formatter: anytype,
) !void {
switch (self) {
.false => try formatter.formatEntry(bool, false),
.true => try formatter.formatEntry(bool, true),
.radius => |v| try formatter.formatEntry(u8, v),
}
}
test "parse BackgroundBlur" {
const testing = std.testing;
var v: BackgroundBlur = undefined;
try v.parseCLI(null);
try testing.expectEqual(.true, v);
try v.parseCLI("true");
try testing.expectEqual(.true, v);
try v.parseCLI("false");
try testing.expectEqual(.false, v);
try v.parseCLI("42");
try testing.expectEqual(42, v.radius);
try testing.expectError(error.InvalidValue, v.parseCLI(""));
try testing.expectError(error.InvalidValue, v.parseCLI("aaaa"));
try testing.expectError(error.InvalidValue, v.parseCLI("420"));
}
};
/// See theme
pub const Theme = struct {
light: []const u8,

View File

@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
ptr.* = @intCast(value);
},
i16 => {
const ptr: *c_short = @ptrCast(@alignCast(ptr_raw));
ptr.* = @intCast(value);
},
f32, f64 => |Float| {
const ptr: *Float = @ptrCast(@alignCast(ptr_raw));
ptr.* = @floatCast(value);
@ -79,6 +84,17 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
ptr.* = @intCast(@as(Backing, @bitCast(value)));
},
.Union => |_| {
if (@hasDecl(T, "cval")) {
const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?;
const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw));
ptr.* = value.cval();
return true;
}
return false;
},
else => return false,
},
}
@ -167,3 +183,30 @@ test "c_get: optional" {
try testing.expectEqual(0, cval.b);
}
}
test "c_get: background-blur" {
const testing = std.testing;
const alloc = testing.allocator;
var c = try Config.default(alloc);
defer c.deinit();
{
c.@"background-blur-radius" = .false;
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur-radius" = .true;
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur-radius" = .{ .radius = 42 };
var cval: u8 = undefined;
try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
}

View File

@ -3,7 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const sentry = @import("sentry");
const build_options = @import("build_options");
const sentry = if (build_options.sentry) @import("sentry");
const internal_os = @import("../os/main.zig");
const crash = @import("main.zig");
const state = &@import("../global.zig").state;
@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null;
/// It is up to the user to grab the logs and manually send them to us
/// (or they own Sentry instance) if they want to.
pub fn init(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
// Not supported on Windows currently, doesn't build.
if (comptime builtin.os.tag == .windows) return;
@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void {
}
fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void {
sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
// Determine the Sentry cache directory.
const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" });
const cache_dir = cache_dir: {
// On macOS, we prefer to use the NSCachesDirectory value to be
// a more idiomatic macOS application. But if XDG env vars are set
// we will respect them.
if (comptime builtin.os.tag == .macos) macos: {
if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos;
break :cache_dir try internal_os.macos.cacheDir(
alloc,
"sentry",
);
}
break :cache_dir try internal_os.xdg.cache(
alloc,
.{ .subdir = "ghostty/sentry" },
);
};
sentry.c.sentry_options_set_database_path_n(
opts,
cache_dir.ptr,
@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void {
/// Process-wide deinitialization of our Sentry client. This ensures all
/// our data is flushed.
pub fn deinit() void {
if (comptime !build_options.sentry) return;
if (comptime builtin.os.tag == .windows) return;
// If we're still initializing then wait for init to finish. This

View File

@ -551,7 +551,7 @@ pub const CoreText = struct {
for (0..result.len) |i| {
result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i);
// We need to retain becauseonce the list is freed it will
// We need to retain because once the list is freed it will
// release all its members.
result[i].retain();
}

View File

@ -100,6 +100,15 @@ pub const RenderOptions = struct {
///
/// This only works with CoreText currently.
thicken: bool = false,
/// "Strength" of the thickening, between `0` and `255`.
/// Only has an effect when `thicken` is enabled.
///
/// `0` does not correspond to *no* thickening,
/// just the *lightest* thickening available.
///
/// CoreText only.
thicken_strength: u8 = 255,
};
test {

View File

@ -354,7 +354,7 @@ pub const Face = struct {
.depth = 1,
.space = try macos.graphics.ColorSpace.createDeviceGray(),
.context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) &
@intFromEnum(macos.graphics.ImageAlphaInfo.none),
@intFromEnum(macos.graphics.ImageAlphaInfo.only),
} else .{
.color = true,
.depth = 4,
@ -398,7 +398,7 @@ pub const Face = struct {
if (color.color)
context.setRGBFillColor(ctx, 1, 1, 1, 0)
else
context.setGrayFillColor(ctx, 0, 0);
context.setGrayFillColor(ctx, 1, 0);
context.fillRect(ctx, .{
.origin = .{ .x = 0, .y = 0 },
.size = .{
@ -421,8 +421,9 @@ pub const Face = struct {
context.setRGBFillColor(ctx, 1, 1, 1, 1);
context.setRGBStrokeColor(ctx, 1, 1, 1, 1);
} else {
context.setGrayFillColor(ctx, 1, 1);
context.setGrayStrokeColor(ctx, 1, 1);
const strength: f64 = @floatFromInt(opts.thicken_strength);
context.setGrayFillColor(ctx, strength / 255.0, 1);
context.setGrayStrokeColor(ctx, strength / 255.0, 1);
}
// If we are drawing with synthetic bold then use a fill stroke

View File

@ -1,6 +1,7 @@
const builtin = @import("builtin");
const options = @import("main.zig").options;
const run = @import("shaper/run.zig");
const feature = @import("shaper/feature.zig");
pub const noop = @import("shaper/noop.zig");
pub const harfbuzz = @import("shaper/harfbuzz.zig");
pub const coretext = @import("shaper/coretext.zig");
@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig");
pub const Cache = @import("shaper/Cache.zig");
pub const TextRun = run.TextRun;
pub const RunIterator = run.RunIterator;
pub const Feature = feature.Feature;
pub const FeatureList = feature.FeatureList;
pub const default_features = feature.default_features;
/// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) {
@ -49,10 +53,7 @@ pub const Cell = struct {
/// Options for shapers.
pub const Options = struct {
/// Font features to use when shaping. These can be in the following
/// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable
/// a feature and the others are used to enable a feature. If a feature
/// isn't supported or is invalid, it will be ignored.
/// Font features to use when shaping.
///
/// Note: eventually, this will move to font.Face probably as we may
/// want to support per-face feature configuration. For now, we only

View File

@ -7,6 +7,9 @@ const trace = @import("tracy").trace;
const font = @import("../main.zig");
const os = @import("../../os/main.zig");
const terminal = @import("../../terminal/main.zig");
const Feature = font.shape.Feature;
const FeatureList = font.shape.FeatureList;
const default_features = font.shape.default_features;
const Face = font.Face;
const Collection = font.Collection;
const DeferredFace = font.DeferredFace;
@ -40,9 +43,10 @@ pub const Shaper = struct {
/// The string used for shaping the current run.
run_state: RunState,
/// The font features we want to use. The hardcoded features are always
/// set first.
features: FeatureList,
/// CoreFoundation Dictionary which represents our font feature settings.
features: *macos.foundation.Dictionary,
/// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// The shared memory used for shaping results.
cell_buf: CellBuf,
@ -100,51 +104,17 @@ pub const Shaper = struct {
}
};
/// List of font features, parsed into the data structures used by
/// the CoreText API. The CoreText API requires a pretty annoying wrapping
/// to setup font features:
///
/// - The key parsed into a CFString
/// - The value parsed into a CFNumber
/// - The key and value are then put into a CFDictionary
/// - The CFDictionary is then put into a CFArray
/// - The CFArray is then put into another CFDictionary
/// - The CFDictionary is then passed to the CoreText API to create
/// a new font with the features set.
///
/// This structure handles up to the point that we have a CFArray of
/// CFDictionary objects representing the font features and provides
/// functions for creating the dictionary to init the font.
const FeatureList = struct {
list: *macos.foundation.MutableArray,
pub fn init() !FeatureList {
var list = try macos.foundation.MutableArray.create();
/// Create a CoreFoundation Dictionary suitable for
/// settings the font features of a CoreText font.
fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
const list = try macos.foundation.MutableArray.create();
errdefer list.release();
return .{ .list = list };
}
pub fn deinit(self: FeatureList) void {
self.list.release();
}
/// Append the given feature to the list. The feature syntax is
/// the same as Harfbuzz: "feat" enables it and "-feat" disables it.
pub fn append(self: *FeatureList, name_raw: []const u8) !void {
// If the name is `-name` then we are disabling the feature,
// otherwise we are enabling it, so we need to parse this out.
const name = if (name_raw[0] == '-') name_raw[1..] else name_raw;
const dict = try featureDict(name, name_raw[0] != '-');
defer dict.release();
self.list.appendValue(macos.foundation.Dictionary, dict);
}
/// Create the dictionary for the given feature and value.
fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary {
const value_num: c_int = @intFromBool(v);
for (feats) |feat| {
const value_num: c_int = @intCast(feat.value);
// Keys can only be ASCII.
var key = try macos.foundation.String.createWithBytes(name, .ascii, false);
var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false);
defer key.release();
var value = try macos.foundation.Number.create(.int, &value_num);
defer value.release();
@ -154,50 +124,44 @@ pub const Shaper = struct {
macos.text.c.kCTFontOpenTypeFeatureTag,
macos.text.c.kCTFontOpenTypeFeatureValue,
},
&[_]?*const anyopaque{
key,
value,
},
&[_]?*const anyopaque{ key, value },
);
errdefer dict.release();
return dict;
}
defer dict.release();
/// Returns the dictionary to use with the font API to set the
/// features. This should be released by the caller.
pub fn attrsDict(
self: FeatureList,
omit_defaults: bool,
) !*macos.foundation.Dictionary {
// Get our feature list. If we're omitting defaults then we
// slice off the hardcoded features.
const list = if (!omit_defaults) self.list else list: {
const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list));
for (hardcoded_features) |_| list.removeValue(0);
break :list list;
};
defer if (omit_defaults) list.release();
list.appendValue(macos.foundation.Dictionary, dict);
}
var dict = try macos.foundation.Dictionary.create(
&[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute},
&[_]?*const anyopaque{list},
);
errdefer dict.release();
return dict;
}
};
// These features are hardcoded to always be on by default. Users
// can turn them off by setting the features to "-liga" for example.
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
var feats = try FeatureList.init();
errdefer feats.deinit();
for (hardcoded_features) |name| try feats.append(name);
for (opts.features) |name| try feats.append(name);
var feature_list: FeatureList = .{};
defer feature_list.deinit(alloc);
for (opts.features) |feature_str| {
try feature_list.appendFromString(alloc, feature_str);
}
// We need to construct two attrs dictionaries for font features;
// one without the default features included, and one with them.
const feats = feature_list.features.items;
const feats_df = try alloc.alloc(Feature, feats.len + default_features.len);
defer alloc.free(feats_df);
@memcpy(feats_df[0..default_features.len], &default_features);
@memcpy(feats_df[default_features.len..], feats);
const features = try makeFeaturesDict(feats_df);
errdefer features.release();
const features_no_default = try makeFeaturesDict(feats);
errdefer features_no_default.release();
var run_state = RunState.init();
errdefer run_state.deinit(alloc);
@ -242,7 +206,8 @@ pub const Shaper = struct {
.alloc = alloc,
.cell_buf = .{},
.run_state = run_state,
.features = feats,
.features = features,
.features_no_default = features_no_default,
.writing_direction = writing_direction,
.cached_fonts = .{},
.cached_font_grid = 0,
@ -255,7 +220,8 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void {
self.cell_buf.deinit(self.alloc);
self.run_state.deinit(self.alloc);
self.features.deinit();
self.features.release();
self.features_no_default.release();
self.writing_direction.release();
{
@ -509,8 +475,8 @@ pub const Shaper = struct {
// If we have it, return the cached attr dict.
if (self.cached_fonts.items[index_int]) |cached| return cached;
// Features dictionary, font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3);
// Font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2);
const run_font = font: {
// The CoreText shaper relies on CoreText and CoreText claims
@ -533,8 +499,10 @@ pub const Shaper = struct {
const face = try grid.resolver.collection.getFace(index);
const original = face.font;
const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features);
self.cf_release_pool.appendAssumeCapacity(attrs);
const attrs = if (face.quirks_disable_default_font_features)
self.features_no_default
else
self.features;
const desc = try macos.text.FontDescriptor.createWithAttributes(attrs);
self.cf_release_pool.appendAssumeCapacity(desc);

390
src/font/shaper/feature.zig Normal file
View File

@ -0,0 +1,390 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.font_shaper);
/// Represents an OpenType font feature setting, which consists of a tag and
/// a numeric parameter >= 0. Most features are boolean, so only parameters
/// of 0 and 1 make sense for them, but some (e.g. 'cv01'..'cv99') can take
/// parameters to choose between multiple variants of a given character or
/// characters.
///
/// Ref:
/// - https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2#features-and-lookups
/// - https://harfbuzz.github.io/shaping-opentype-features.html
pub const Feature = struct {
tag: [4]u8,
value: u32,
pub fn fromString(str: []const u8) ?Feature {
var fbs = std.io.fixedBufferStream(str);
const reader = fbs.reader();
return Feature.fromReader(reader);
}
/// Parse a single font feature setting from a std.io.Reader, with a version
/// of the syntax of HarfBuzz's font feature strings. Stops at end of stream
/// or when a ',' is encountered.
///
/// This parsing aims to be as error-tolerant as possible while avoiding any
/// assumptions in ambiguous scenarios. When invalid syntax is encountered,
/// the reader is advanced to the next boundary (end-of-stream or ',') so
/// that further features may be read.
///
/// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string
pub fn fromReader(reader: anytype) ?Feature {
var tag: [4]u8 = undefined;
var value: ?u32 = null;
// TODO: when we move to Zig 0.14 this can be replaced with a
// labeled switch continue pattern rather than this loop.
var state: union(enum) {
/// Initial state.
start: void,
/// Parsing the tag, data is index.
tag: u2,
/// In the space between the tag and the value.
space: void,
/// Parsing an integer parameter directly in to `value`.
int: void,
/// Parsing a boolean keyword parameter ("on"/"off").
bool: void,
/// Encountered an unrecoverable syntax error, advancing to boundary.
err: void,
/// Done parsing feature.
done: void,
} = .start;
while (true) {
// If we hit the end of the stream we just pretend it's a comma.
const byte = reader.readByte() catch ',';
switch (state) {
// If we're done then we skip whitespace until we see a ','.
.done => switch (byte) {
' ', '\t' => continue,
',' => break,
// If we see something other than whitespace or a ','
// then this is an error since the intent is unclear.
else => {
state = .err;
continue;
},
},
// If we're fast-forwarding from an error we just wanna
// stop at the first boundary and ignore all other bytes.
.err => if (byte == ',') return null,
.start => switch (byte) {
// Ignore leading whitespace.
' ', '\t' => continue,
// Empty feature string.
',' => return null,
// '+' prefix to explicitly enable feature.
'+' => {
value = 1;
state = .{ .tag = 0 };
continue;
},
// '-' prefix to explicitly disable feature.
'-' => {
value = 0;
state = .{ .tag = 0 };
continue;
},
// Quote mark introducing a tag.
'"', '\'' => {
state = .{ .tag = 0 };
continue;
},
// First letter of tag.
else => {
tag[0] = byte;
state = .{ .tag = 1 };
continue;
},
},
.tag => |*i| switch (byte) {
// If the tag is interrupted by a comma it's invalid.
',' => return null,
// Ignore quote marks.
'"', '\'' => continue,
// A prefix of '+' or '-'
// In all other cases we add the byte to our tag.
else => {
tag[i.*] = byte;
if (i.* == 3) {
state = .space;
continue;
}
i.* += 1;
},
},
.space => switch (byte) {
' ', '\t' => continue,
// Ignore quote marks since we might have a
// closing quote from the tag still ahead.
'"', '\'' => continue,
// Allow an '=' (which we can safely ignore)
// only if we don't already have a value due
// to a '+' or '-' prefix.
'=' => if (value != null) {
state = .err;
continue;
},
',' => {
// Specifying only a tag turns a feature on.
if (value == null) value = 1;
break;
},
'0'...'9' => {
// If we already have value because of a
// '+' or '-' prefix then this is an error.
if (value != null) {
state = .err;
continue;
}
value = byte - '0';
state = .int;
continue;
},
'o', 'O' => {
// If we already have value because of a
// '+' or '-' prefix then this is an error.
if (value != null) {
state = .err;
continue;
}
state = .bool;
continue;
},
else => {
state = .err;
continue;
},
},
.int => switch (byte) {
',' => break,
'0'...'9' => {
// If our value gets too big while
// parsing we consider it an error.
value = std.math.mul(u32, value.?, 10) catch {
state = .err;
continue;
};
value.? += byte - '0';
},
else => {
state = .err;
continue;
},
},
.bool => switch (byte) {
',' => return null,
'n', 'N' => {
// "ofn"
if (value != null) {
assert(value == 0);
state = .err;
continue;
}
value = 1;
state = .done;
continue;
},
'f', 'F' => {
// To make sure we consume two 'f's.
if (value == null) {
value = 0;
} else {
assert(value == 0);
state = .done;
continue;
}
},
else => {
state = .err;
continue;
},
},
}
}
assert(value != null);
return .{
.tag = tag,
.value = value.?,
};
}
/// Serialize this feature to the provided buffer.
/// The string that this produces should be valid to parse.
pub fn toString(self: *const Feature, buf: []u8) !void {
var fbs = std.io.fixedBufferStream(buf);
try self.format("", .{}, fbs.writer());
}
/// Formatter for logging
pub fn format(
self: Feature,
comptime layout: []const u8,
opts: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = layout;
_ = opts;
if (self.value <= 1) {
// Format boolean options as "+tag" for on and "-tag" for off.
try std.fmt.format(writer, "{c}{s}", .{
"-+"[self.value],
self.tag,
});
} else {
// Format non-boolean tags as "tag=value".
try std.fmt.format(writer, "{s}={d}", .{
self.tag,
self.value,
});
}
}
};
/// A list of font feature settings (see `Feature` for more documentation).
pub const FeatureList = struct {
features: std.ArrayListUnmanaged(Feature) = .{},
pub fn deinit(self: *FeatureList, alloc: Allocator) void {
self.features.deinit(alloc);
}
/// Parse a comma separated list of features.
/// See `Feature.fromReader` for more docs.
pub fn fromString(alloc: Allocator, str: []const u8) !FeatureList {
var self: FeatureList = .{};
try self.appendFromString(alloc, str);
return self;
}
/// Append features to this list from a string with a comma separated list.
/// See `Feature.fromReader` for more docs.
pub fn appendFromString(
self: *FeatureList,
alloc: Allocator,
str: []const u8,
) !void {
var fbs = std.io.fixedBufferStream(str);
const reader = fbs.reader();
while (fbs.pos < fbs.buffer.len) {
const i = fbs.pos;
if (Feature.fromReader(reader)) |feature| {
try self.features.append(alloc, feature);
} else log.warn(
"failed to parse font feature setting: \"{s}\"",
.{fbs.buffer[i..fbs.pos]},
);
}
}
/// Formatter for logging
pub fn format(
self: FeatureList,
comptime layout: []const u8,
opts: std.fmt.FormatOptions,
writer: anytype,
) !void {
for (self.features.items, 0..) |feature, i| {
try feature.format(layout, opts, writer);
if (i != std.features.items.len - 1) try writer.writeAll(", ");
}
if (self.value <= 1) {
// Format boolean options as "+tag" for on and "-tag" for off.
try std.fmt.format(writer, "{c}{s}", .{
"-+"[self.value],
self.tag,
});
} else {
// Format non-boolean tags as "tag=value".
try std.fmt.format(writer, "{s}={d}", .{
self.tag,
self.value,
});
}
}
};
/// These features are hardcoded to always be on by default. Users
/// can turn them off by setting the features to "-liga" for example.
pub const default_features = [_]Feature{
.{ .tag = "dlig".*, .value = 1 },
.{ .tag = "liga".*, .value = 1 },
};
test "Feature.fromString" {
const testing = std.testing;
// This is not *complete* coverage of every possible
// combination of syntax, but it covers quite a few.
// Boolean settings (on)
const kern_on = Feature{ .tag = "kern".*, .value = 1 };
try testing.expectEqual(kern_on, Feature.fromString("kern"));
try testing.expectEqual(kern_on, Feature.fromString("kern, "));
try testing.expectEqual(kern_on, Feature.fromString("kern on"));
try testing.expectEqual(kern_on, Feature.fromString("kern on, "));
try testing.expectEqual(kern_on, Feature.fromString("+kern"));
try testing.expectEqual(kern_on, Feature.fromString("+kern, "));
try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1"));
try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1, "));
// Boolean settings (off)
const kern_off = Feature{ .tag = "kern".*, .value = 0 };
try testing.expectEqual(kern_off, Feature.fromString("kern off"));
try testing.expectEqual(kern_off, Feature.fromString("kern off, "));
try testing.expectEqual(kern_off, Feature.fromString("-'kern'"));
try testing.expectEqual(kern_off, Feature.fromString("-'kern', "));
try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0"));
try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0, "));
// Non-boolean settings
const aalt_2 = Feature{ .tag = "aalt".*, .value = 2 };
try testing.expectEqual(aalt_2, Feature.fromString("aalt=2"));
try testing.expectEqual(aalt_2, Feature.fromString("aalt=2, "));
try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2"));
try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2, "));
// Various ambiguous/error cases which should be null
try testing.expectEqual(null, Feature.fromString("aalt=2x")); // bad number
try testing.expectEqual(null, Feature.fromString("toolong")); // tag too long
try testing.expectEqual(null, Feature.fromString("sht")); // tag too short
try testing.expectEqual(null, Feature.fromString("-kern 1")); // redundant/conflicting
try testing.expectEqual(null, Feature.fromString("-kern on")); // redundant/conflicting
try testing.expectEqual(null, Feature.fromString("aalt=o,")); // bad keyword
try testing.expectEqual(null, Feature.fromString("aalt=ofn,")); // bad keyword
}
test "FeatureList.fromString" {
const testing = std.testing;
const str =
" kern, kern on , +kern, \"kern\" = 1," ++ // Boolean settings (on)
"kern off, -'kern' , \"kern\"=0," ++ // Boolean settings (off)
"aalt=2, 'aalt'\t2," ++ // Non-boolean settings
"aalt=2x, toolong, sht, -kern 1, -kern on, aalt=o, aalt=ofn," ++ // Invalid cases
"last"; // To ensure final element is included correctly.
var feats = try FeatureList.fromString(testing.allocator, str);
defer feats.deinit(testing.allocator);
try testing.expectEqualSlices(
Feature,
&(.{Feature{ .tag = "kern".*, .value = 1 }} ** 4 ++
.{Feature{ .tag = "kern".*, .value = 0 }} ** 3 ++
.{Feature{ .tag = "aalt".*, .value = 2 }} ** 2 ++
.{Feature{ .tag = "last".*, .value = 1 }}),
feats.features.items,
);
}

View File

@ -3,6 +3,10 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const harfbuzz = @import("harfbuzz");
const font = @import("../main.zig");
const terminal = @import("../../terminal/main.zig");
const Feature = font.shape.Feature;
const FeatureList = font.shape.FeatureList;
const default_features = font.shape.default_features;
const Face = font.Face;
const Collection = font.Collection;
const DeferredFace = font.DeferredFace;
@ -10,7 +14,6 @@ const Library = font.Library;
const SharedGrid = font.SharedGrid;
const Style = font.Style;
const Presentation = font.Presentation;
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.font_shaper);
@ -27,38 +30,37 @@ pub const Shaper = struct {
cell_buf: CellBuf,
/// The features to use for shaping.
hb_feats: FeatureList,
hb_feats: []harfbuzz.Feature,
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell);
const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature);
// These features are hardcoded to always be on by default. Users
// can turn them off by setting the features to "-liga" for example.
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
// Parse all the features we want to use. We use
var hb_feats = hb_feats: {
var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len);
errdefer list.deinit(alloc);
for (hardcoded_features) |name| {
if (harfbuzz.Feature.fromString(name)) |feat| {
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
// Parse all the features we want to use.
const hb_feats = hb_feats: {
var feature_list: FeatureList = .{};
defer feature_list.deinit(alloc);
try feature_list.features.appendSlice(alloc, &default_features);
for (opts.features) |feature_str| {
try feature_list.appendFromString(alloc, feature_str);
}
for (opts.features) |name| {
if (harfbuzz.Feature.fromString(name)) |feat| {
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len);
errdefer alloc.free(list);
for (feature_list.features.items, 0..) |feature, i| {
list[i] = .{
.tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)),
.value = feature.value,
.start = harfbuzz.c.HB_FEATURE_GLOBAL_START,
.end = harfbuzz.c.HB_FEATURE_GLOBAL_END,
};
}
break :hb_feats list;
};
errdefer hb_feats.deinit(alloc);
errdefer alloc.free(hb_feats);
return Shaper{
.alloc = alloc,
@ -71,7 +73,7 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void {
self.hb_buf.destroy();
self.cell_buf.deinit(self.alloc);
self.hb_feats.deinit(self.alloc);
self.alloc.free(self.hb_feats);
}
pub fn endFrame(self: *const Shaper) void {
@ -125,10 +127,10 @@ pub const Shaper = struct {
// If we are disabling default font features we just offset
// our features by the hardcoded items because always
// add those at the beginning.
break :i hardcoded_features.len;
break :i default_features.len;
};
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]);
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]);
}
// If our buffer is empty, we short-circuit the rest of the work

View File

@ -27,6 +27,7 @@ pub const GlobalState = struct {
alloc: std.mem.Allocator,
action: ?cli.Action,
logging: Logging,
rlimits: ResourceLimits = .{},
/// The app resources directory, equivalent to zig-out/share when we build
/// from source. This is null if we can't detect it.
@ -56,6 +57,7 @@ pub const GlobalState = struct {
.alloc = undefined,
.action = null,
.logging = .{ .stderr = {} },
.rlimits = .{},
.resources_dir = null,
};
errdefer self.deinit();
@ -123,8 +125,8 @@ pub const GlobalState = struct {
std.log.info("renderer={}", .{renderer.Renderer});
std.log.info("libxev backend={}", .{xev.backend});
// First things first, we fix our file descriptors
internal_os.fixMaxFiles();
// As early as possible, initialize our resource limits.
self.rlimits = ResourceLimits.init();
// Initialize our crash reporting.
crash.init(self.alloc) catch |err| {
@ -174,3 +176,21 @@ pub const GlobalState = struct {
}
}
};
/// Maintains the Unix resource limits that we set for our process. This
/// can be used to restore the limits to their original values.
pub const ResourceLimits = struct {
nofile: ?internal_os.rlimit = null,
pub fn init() ResourceLimits {
return .{
// Maximize the number of file descriptors we can have open
// because we can consume a lot of them if we make many terminals.
.nofile = internal_os.fixMaxFiles(),
};
}
pub fn restore(self: *const ResourceLimits) void {
if (self.nofile) |lim| internal_os.restoreMaxFiles(lim);
}
};

View File

@ -36,6 +36,11 @@ pub const Flags = packed struct {
/// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information.
global: bool = false,
/// True if this binding should only be triggered if the action can be
/// performed. If the action can't be performed then the binding acts as
/// if it doesn't exist.
performable: bool = false,
};
/// Full binding parser. The binding parser is implemented as an iterator
@ -90,6 +95,9 @@ pub const Parser = struct {
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false;
} else if (std.mem.eql(u8, prefix, "performable")) {
if (flags.performable) return Error.InvalidFormat;
flags.performable = true;
} else {
// If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so
@ -185,10 +193,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool {
if (rhs.trigger.mods.alt) count += 1;
break :blk count;
};
if (lhs_count == rhs_count)
if (lhs_count != rhs_count)
return lhs_count > rhs_count;
if (lhs.trigger.mods.int() != rhs.trigger.mods.int())
return lhs.trigger.mods.int() > rhs.trigger.mods.int();
return lhs_count > rhs_count;
const lhs_key: c_int = blk: {
switch (lhs.trigger.key) {
.translated => break :blk @intFromEnum(lhs.trigger.key.translated),
.physical => break :blk @intFromEnum(lhs.trigger.key.physical),
.unicode => break :blk @intCast(lhs.trigger.key.unicode),
}
};
const rhs_key: c_int = blk: {
switch (rhs.trigger.key) {
.translated => break :blk @intFromEnum(rhs.trigger.key.translated),
.physical => break :blk @intFromEnum(rhs.trigger.key.physical),
.unicode => break :blk @intCast(rhs.trigger.key.unicode),
}
};
return lhs_key < rhs_key;
}
/// The set of actions that a keybinding can take.
@ -203,7 +230,7 @@ pub const Action = union(enum) {
unbind: void,
/// Send a CSI sequence. The value should be the CSI sequence without the
/// CSI header (`ESC ]` or `\x1b]`).
/// CSI header (`ESC [` or `\x1b[`).
csi: []const u8,
/// Send an `ESC` sequence.
@ -302,7 +329,7 @@ pub const Action = union(enum) {
goto_tab: usize,
/// Moves a tab by a relative offset.
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 for right).
/// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right.
/// If the new position is out of bounds, it wraps around cyclically within the tab range.
move_tab: isize,
@ -311,17 +338,18 @@ pub const Action = union(enum) {
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given.
/// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto.
new_split: SplitDirection,
/// Focus on a split in a given direction.
/// Focus on a split in a given direction. For example `goto_split:up`.
/// Valid values are left, right, up, down, previous and next.
goto_split: SplitFocusDirection,
/// zoom/unzoom the current split.
toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given
/// direction
/// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right.
resize_split: SplitResizeParameter,
/// Equalize all splits in the current window
@ -478,10 +506,42 @@ pub const Action = union(enum) {
previous,
next,
top,
up,
left,
bottom,
down,
right,
pub fn parse(input: []const u8) !SplitFocusDirection {
return std.meta.stringToEnum(SplitFocusDirection, input) orelse {
// For backwards compatibility we map "top" and "bottom" onto the enum
// values "up" and "down"
if (std.mem.eql(u8, input, "top")) {
return .up;
} else if (std.mem.eql(u8, input, "bottom")) {
return .down;
} else {
return Error.InvalidFormat;
}
};
}
test "parse" {
const testing = std.testing;
try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous"));
try testing.expectEqual(.next, try SplitFocusDirection.parse("next"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("up"));
try testing.expectEqual(.left, try SplitFocusDirection.parse("left"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("down"));
try testing.expectEqual(.right, try SplitFocusDirection.parse("right"));
try testing.expectEqual(.up, try SplitFocusDirection.parse("top"));
try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom"));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse(""));
try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green"));
}
};
pub const SplitResizeDirection = enum {
@ -524,7 +584,16 @@ pub const Action = union(enum) {
comptime field: std.builtin.Type.UnionField,
param: []const u8,
) !field.type {
return switch (@typeInfo(field.type)) {
const field_info = @typeInfo(field.type);
// Fields can provide a custom "parse" function
if (field_info == .Struct or field_info == .Union or field_info == .Enum) {
if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) {
return field.type.parse(param);
}
}
return switch (field_info) {
.Enum => try parseEnum(field.type, param),
.Int => try parseInt(field.type, param),
.Float => try parseFloat(field.type, param),
@ -1647,6 +1716,16 @@ test "parse: triggers" {
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// performable keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .performable = true },
}, try parseSingle("performable:shift+a=ignore"));
// invalid key
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));

View File

@ -282,7 +282,12 @@ fn legacy(
// If we match a control sequence, we output that directly. For
// ctrlSeq we have to use all mods because we want it to only
// match ctrl+<char>.
if (ctrlSeq(self.event.utf8, self.event.unshifted_codepoint, all_mods)) |char| {
if (ctrlSeq(
self.event.key,
self.event.utf8,
self.event.unshifted_codepoint,
all_mods,
)) |char| {
// C0 sequences support alt-as-esc prefixing.
if (binding_mods.alt) {
if (buf.len < 2) return error.OutOfMemory;
@ -538,19 +543,17 @@ fn pcStyleFunctionKey(
/// into a C0 byte. There are many cases for this and you should read
/// the source code to understand them.
fn ctrlSeq(
logical_key: key.Key,
utf8: []const u8,
unshifted_codepoint: u21,
mods: key.Mods,
) ?u8 {
const ctrl_only = comptime (key.Mods{ .ctrl = true }).int();
// If ctrl is not pressed then we never do anything.
if (!mods.ctrl) return null;
// If we don't have exactly one byte in our utf8 sequence, then
// we don't do anything, since all our ctrl keys are based on ASCII.
if (utf8.len != 1) return null;
const char, const unset_mods = unset_mods: {
var char = utf8[0];
var unset_mods = mods;
// Remove alt from our modifiers because it does not impact whether
@ -558,6 +561,34 @@ fn ctrlSeq(
// logic separately.
unset_mods.alt = false;
var char: u8 = char: {
// If we have exactly one UTF8 byte, we assume that is the
// character we want to convert to a C0 byte.
if (utf8.len == 1) break :char utf8[0];
// If we have a logical key that maps to a single byte
// printable character, we use that. History to explain this:
// this was added to support cyrillic keyboard layouts such
// as Russian and Mongolian. These layouts have a `c` key that
// maps to U+0441 (cyrillic small letter "c") but every
// terminal I've tested encodes this as ctrl+c.
if (logical_key.codepoint()) |cp| {
if (std.math.cast(u8, cp)) |byte| {
// For this specific case, we only map to the key if
// we have exactly ctrl pressed. This is because shift
// would modify the key and we don't know how to do that
// properly here (don't have the layout). And we want
// to encode shift as CSIu.
if (unset_mods.int() != ctrl_only) return null;
break :char byte;
}
}
// Otherwise we don't have a character to convert that
// we can reliably map to a C0 byte.
return null;
};
// Remove shift if we have something outside of the US letter
// range. This is so that characters such as `ctrl+shift+-`
// generate the correct ctrl-seq (used by emacs).
@ -596,7 +627,6 @@ fn ctrlSeq(
};
// After unsetting, we only continue if we have ONLY control set.
const ctrl_only = comptime (key.Mods{ .ctrl = true }).int();
if (unset_mods.int() != ctrl_only) return null;
// From Kitty's key encoding logic. I tried to discern the exact
@ -2132,36 +2162,52 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" {
const actual = try enc.legacy(&buf);
try testing.expectEqualStrings("[337;5u", actual[1..]);
}
test "ctrlseq: normal ctrl c" {
const seq = ctrlSeq("c", 'c', .{ .ctrl = true });
const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
test "ctrlseq: normal ctrl c, right control" {
const seq = ctrlSeq("c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } });
const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
test "ctrlseq: alt should be allowed" {
const seq = ctrlSeq("c", 'c', .{ .alt = true, .ctrl = true });
const seq = ctrlSeq(.invalid, "c", 'c', .{ .alt = true, .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
test "ctrlseq: no ctrl does nothing" {
try testing.expect(ctrlSeq("c", 'c', .{}) == null);
try testing.expect(ctrlSeq(.invalid, "c", 'c', .{}) == null);
}
test "ctrlseq: shifted non-character" {
const seq = ctrlSeq("_", '-', .{ .ctrl = true, .shift = true });
const seq = ctrlSeq(.invalid, "_", '-', .{ .ctrl = true, .shift = true });
try testing.expectEqual(@as(u8, 0x1F), seq.?);
}
test "ctrlseq: caps ascii letter" {
const seq = ctrlSeq("C", 'c', .{ .ctrl = true, .caps_lock = true });
const seq = ctrlSeq(.invalid, "C", 'c', .{ .ctrl = true, .caps_lock = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
test "ctrlseq: shift does not generate ctrl seq" {
try testing.expect(ctrlSeq("C", 'c', .{ .shift = true }) == null);
try testing.expect(ctrlSeq("C", 'c', .{ .shift = true, .ctrl = true }) == null);
try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true }) == null);
try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true, .ctrl = true }) == null);
}
test "ctrlseq: russian ctrl c" {
const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
test "ctrlseq: russian shifted ctrl c" {
const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .shift = true });
try testing.expect(seq == null);
}
test "ctrlseq: russian alt ctrl c" {
const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .alt = true });
try testing.expectEqual(@as(u8, 0x03), seq.?);
}

View File

@ -295,7 +295,7 @@ pub const Key = enum(c_int) {
eight,
nine,
// puncuation
// punctuation
semicolon,
space,
apostrophe,
@ -411,7 +411,7 @@ pub const Key = enum(c_int) {
/// may be from the number row or the keypad, but it always maps
/// to '.zero'.
///
/// This is what we want, we awnt people to create keybindings that
/// This is what we want, we want people to create keybindings that
/// are independent of the physical key.
pub fn fromASCII(ch: u8) ?Key {
return switch (ch) {

View File

@ -14,6 +14,7 @@ const input = @import("../input.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const inspector = @import("main.zig");
const units = @import("units.zig");
/// The window names. These are used with docking so we need to have access.
const window_cell = "Cell";
@ -440,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%d bytes", kitty_images.total_bytes);
cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes));
}
}
@ -452,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%d bytes", kitty_images.total_limit);
cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit));
}
}
@ -518,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%d bytes", pages.page_size);
cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size));
}
}
@ -530,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%d bytes", pages.maxSize());
cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize()));
}
}
@ -724,7 +725,7 @@ fn renderSizeWindow(self: *Inspector) void {
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText(
"%d pt",
"%.2f pt",
self.surface.font_size.points,
);
}

View File

@ -3,6 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const cimgui = @import("cimgui");
const terminal = @import("../terminal/main.zig");
const inspector = @import("main.zig");
const units = @import("units.zig");
pub fn render(page: *const terminal.Page) void {
cimgui.c.igPushID_Ptr(page);
@ -25,7 +27,7 @@ pub fn render(page: *const terminal.Page) void {
}
{
_ = cimgui.c.igTableSetColumnIndex(1);
cimgui.c.igText("%d bytes", page.memory.len);
cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len));
cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size);
}
}

View File

@ -35,7 +35,7 @@ pub const VTEvent = struct {
const Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8);
/// Initiaze the event information for the given parser action.
/// Initialize the event information for the given parser action.
pub fn init(
alloc: Allocator,
surface: *Surface,
@ -208,6 +208,20 @@ pub const VTEvent = struct {
);
},
.Union => |info| {
const Tag = info.tag_type orelse @compileError("Unions must have a tag");
const tag_name = @tagName(@as(Tag, v));
inline for (info.fields) |field| {
if (std.mem.eql(u8, field.name, tag_name)) {
if (field.type == void) {
break try md.put("data", tag_name);
} else {
break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name));
}
}
}
},
else => {
@compileLog(T);
@compileError("unsupported type, see log");

3
src/inspector/units.zig Normal file
View File

@ -0,0 +1,3 @@
pub fn toKibiBytes(bytes: usize) usize {
return bytes / 1024;
}

View File

@ -49,7 +49,8 @@ pub fn main() !MainReturn {
error.InvalidAction => try stderr.print(
"Error: unknown CLI action specified. CLI actions are specified with\n" ++
"the '+' character.\n",
"the '+' character.\n\n" ++
"All valid CLI actions can be listed with `ghostty +help`\n",
.{},
),

View File

@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool {
else => @compileError("unsupported platform"),
};
}
pub const DesktopEnvironment = enum {
gnome,
macos,
other,
windows,
};
/// Detect what desktop environment we are running under. This is mainly used on
/// Linux to enable or disable GTK client-side decorations but there may be more
/// uses in the future.
pub fn desktopEnvironment() DesktopEnvironment {
return switch (comptime builtin.os.tag) {
.macos => .macos,
.windows => .windows,
.linux => de: {
if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime.");
// use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux
// https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop=
const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other;
if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome;
break :de .other;
},
else => .other,
};
}

View File

@ -4,24 +4,27 @@ const posix = std.posix;
const log = std.log.scoped(.os);
pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {};
/// This maximizes the number of file descriptors we can have open. We
/// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code.
pub fn fixMaxFiles() void {
if (!@hasDecl(posix.system, "rlimit")) return;
pub fn fixMaxFiles() ?rlimit {
if (!@hasDecl(posix.system, "rlimit")) return null;
var lim = posix.getrlimit(.NOFILE) catch {
const old = posix.getrlimit(.NOFILE) catch {
log.warn("failed to query file handle limit, may limit max windows", .{});
return; // Oh well; we tried.
return null; // Oh well; we tried.
};
// If we're already at the max, we're done.
if (lim.cur >= lim.max) {
log.debug("file handle limit already maximized value={}", .{lim.cur});
return;
if (old.cur >= old.max) {
log.debug("file handle limit already maximized value={}", .{old.cur});
return old;
}
// Do a binary search for the limit.
var lim = old;
var min: posix.rlim_t = lim.cur;
var max: posix.rlim_t = 1 << 20;
// But if there's a defined upper bound, don't search, just set it.
@ -41,6 +44,12 @@ pub fn fixMaxFiles() void {
}
log.debug("file handle limit raised value={}", .{lim.cur});
return old;
}
pub fn restoreMaxFiles(lim: rlimit) void {
if (!@hasDecl(posix.system, "rlimit")) return;
posix.setrlimit(.NOFILE, lim) catch {};
}
/// Return the recommended path for temporary files.

View File

@ -12,7 +12,7 @@ const Error = error{
/// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached.
pub inline fn home(buf: []u8) !?[]u8 {
pub inline fn home(buf: []u8) !?[]const u8 {
return switch (builtin.os.tag) {
inline .linux, .macos => try homeUnix(buf),
.windows => try homeWindows(buf),
@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 {
};
}
fn homeUnix(buf: []u8) !?[]u8 {
fn homeUnix(buf: []u8) !?[]const u8 {
// First: if we have a HOME env var, then we use that.
if (posix.getenv("HOME")) |result| {
if (buf.len < result.len) return Error.BufferTooSmall;
@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 {
return null;
}
fn homeWindows(buf: []u8) !?[]u8 {
fn homeWindows(buf: []u8) !?[]const u8 {
const drive_len = blk: {
var fba_instance = std.heap.FixedBufferAllocator.init(buf);
const fba = fba_instance.allocator();
@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 {
return std.mem.trim(u8, input, " \n\t");
}
pub const ExpandError = error{
HomeDetectionFailed,
BufferTooSmall,
};
/// Expands a path that starts with a tilde (~) to the home directory of
/// the current user.
///
/// Errors if `home` fails or if the size of the expanded path is larger
/// than `buf.len`.
pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 {
return switch (builtin.os.tag) {
.linux, .macos => try expandHomeUnix(path, buf),
.ios => return path,
else => @compileError("unimplemented"),
};
}
fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 {
if (!std.mem.startsWith(u8, path, "~/")) return path;
const home_dir: []const u8 = if (home(buf)) |home_|
home_ orelse return error.HomeDetectionFailed
else |_|
return error.HomeDetectionFailed;
const rest = path[1..]; // Skip the ~
const expanded_len = home_dir.len + rest.len;
if (expanded_len > buf.len) return Error.BufferTooSmall;
@memcpy(buf[home_dir.len..expanded_len], rest);
return buf[0..expanded_len];
}
test "expandHomeUnix" {
const testing = std.testing;
const allocator = testing.allocator;
var buf: [std.fs.max_path_bytes]u8 = undefined;
const home_dir = try expandHomeUnix("~/", &buf);
// Joining the home directory `~` with the path `/`
// the result should end with a separator here. (e.g. `/home/user/`)
try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep);
const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf);
const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" });
defer allocator.free(expected_downloads);
try testing.expectEqualStrings(expected_downloads, downloads);
try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf));
try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf));
try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf));
try testing.expectEqualStrings("", try expandHomeUnix("", &buf));
// Expect an error if the buffer is large enough to hold the home directory,
// but not the expanded path
var small_buf = try allocator.alloc(u8, home_dir.len);
defer allocator.free(small_buf);
try testing.expectError(error.BufferTooSmall, expandHomeUnix(
"~/Downloads",
small_buf[0..],
));
}
test {
const testing = std.testing;

View File

@ -25,41 +25,26 @@ pub fn appSupportDir(
alloc: Allocator,
sub_path: []const u8,
) AppSupportDirError![]const u8 {
comptime assert(builtin.target.isDarwin());
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(
objc.Object,
objc.sel("defaultManager"),
.{},
return try commonDir(
alloc,
.NSApplicationSupportDirectory,
&.{ build_config.bundle_id, sub_path },
);
}
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
NSSearchPathDirectory.NSApplicationSupportDirectory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
pub const CacheDirError = Allocator.Error || error{AppleAPIFailed};
/// Return the path to the system cache directory with the given sub path joined.
/// This allocates the result using the given allocator.
pub fn cacheDir(
alloc: Allocator,
sub_path: []const u8,
) CacheDirError![]const u8 {
return try commonDir(
alloc,
.NSCachesDirectory,
&.{ build_config.bundle_id, sub_path },
);
// I don't think this is possible but just in case.
if (url.value == null) return error.AppleAPIFailed;
// Get the UTF-8 string from the URL.
const path = url.getProperty(objc.Object, "path");
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
return error.AppleAPIFailed;
const app_support_dir = std.mem.sliceTo(c_str, 0);
return try std.fs.path.join(alloc, &.{
app_support_dir,
build_config.bundle_id,
sub_path,
});
}
pub const SetQosClassError = error{
@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct {
};
pub const NSSearchPathDirectory = enum(c_ulong) {
NSCachesDirectory = 13,
NSApplicationSupportDirectory = 14,
};
pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1,
};
fn commonDir(
alloc: Allocator,
directory: NSSearchPathDirectory,
sub_paths: []const []const u8,
) (error{AppleAPIFailed} || Allocator.Error)![]const u8 {
comptime assert(builtin.target.isDarwin());
const NSFileManager = objc.getClass("NSFileManager").?;
const manager = NSFileManager.msgSend(
objc.Object,
objc.sel("defaultManager"),
.{},
);
const url = manager.msgSend(
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
.{
directory,
NSSearchPathDomainMask.NSUserDomainMask,
@as(?*anyopaque, null),
true,
@as(?*anyopaque, null),
},
);
if (url.value == null) return error.AppleAPIFailed;
const path = url.getProperty(objc.Object, "path");
const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse
return error.AppleAPIFailed;
const base_dir = std.mem.sliceTo(c_str, 0);
// Create a new array with base_dir as the first element
var paths = try alloc.alloc([]const u8, sub_paths.len + 1);
paths[0] = base_dir;
@memcpy(paths[1..], sub_paths);
defer alloc.free(paths);
return try std.fs.path.join(alloc, paths);
}
test "cacheDir paths" {
if (!builtin.target.isDarwin()) return;
const testing = std.testing;
const alloc = testing.allocator;
// Test base path
{
const cache_path = try cacheDir(alloc, "");
defer alloc.free(cache_path);
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
}
// Test with subdir
{
const cache_path = try cacheDir(alloc, "test");
defer alloc.free(cache_path);
try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null);
try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null);
const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id});
defer alloc.free(bundle_path);
try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null);
}
}

View File

@ -32,12 +32,16 @@ pub const getenv = env.getenv;
pub const setenv = env.setenv;
pub const unsetenv = env.unsetenv;
pub const launchedFromDesktop = desktop.launchedFromDesktop;
pub const desktopEnvironment = desktop.desktopEnvironment;
pub const rlimit = file.rlimit;
pub const fixMaxFiles = file.fixMaxFiles;
pub const restoreMaxFiles = file.restoreMaxFiles;
pub const allocTmpDir = file.allocTmpDir;
pub const freeTmpDir = file.freeTmpDir;
pub const isFlatpak = flatpak.isFlatpak;
pub const FlatpakHostCommand = flatpak.FlatpakHostCommand;
pub const home = homedir.home;
pub const expandHome = homedir.expandHome;
pub const ensureLocale = locale.ensureLocale;
pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open;

View File

@ -58,6 +58,15 @@ fn dir(
opts: Options,
internal_opts: InternalOptions,
) ![]u8 {
// If we have a cached home dir, use that.
if (opts.home) |home| {
return try std.fs.path.join(alloc, &[_][]const u8{
home,
internal_opts.default_subdir,
opts.subdir orelse "",
});
}
// First check the env var. On Windows we have to allocate so this tracks
// both whether we have the env var and whether we own it.
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
@ -93,15 +102,6 @@ fn dir(
return try alloc.dupe(u8, env);
}
// If we have a cached home dir, use that.
if (opts.home) |home| {
return try std.fs.path.join(alloc, &[_][]const u8{
home,
internal_opts.default_subdir,
opts.subdir orelse "",
});
}
// Get our home dir
var buf: [1024]u8 = undefined;
if (try homedir.home(&buf)) |home| {
@ -143,6 +143,32 @@ test {
}
}
test "cache directory paths" {
const testing = std.testing;
const alloc = testing.allocator;
const mock_home = "/Users/test";
// Test when XDG_CACHE_HOME is not set
{
// Test base path
{
const cache_path = try cache(alloc, .{ .home = mock_home });
defer alloc.free(cache_path);
try testing.expectEqualStrings("/Users/test/.cache", cache_path);
}
// Test with subdir
{
const cache_path = try cache(alloc, .{
.home = mock_home,
.subdir = "ghostty",
});
defer alloc.free(cache_path);
try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path);
}
}
}
test parseTerminalExec {
const testing = std.testing;

View File

@ -360,6 +360,7 @@ pub const DerivedConfig = struct {
arena: ArenaAllocator,
font_thicken: bool,
font_thicken_strength: u8,
font_features: std.ArrayListUnmanaged([:0]const u8),
font_styles: font.CodepointResolver.StyleStatus,
cursor_color: ?terminal.color.RGB,
@ -410,6 +411,7 @@ pub const DerivedConfig = struct {
return .{
.background_opacity = @max(0, @min(1, config.@"background-opacity")),
.font_thicken = config.@"font-thicken",
.font_thicken_strength = config.@"font-thicken-strength",
.font_features = font_features.list,
.font_styles = font_styles,
@ -2837,6 +2839,7 @@ fn addGlyph(
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
.thicken_strength = self.config.font_thicken_strength,
},
);

View File

@ -146,7 +146,7 @@ image_bg_end: u32 = 0,
image_text_end: u32 = 0,
image_virtual: bool = false,
/// Defererred OpenGL operation to update the screen size.
/// Deferred OpenGL operation to update the screen size.
const SetScreenSize = struct {
size: renderer.Size,
@ -272,6 +272,7 @@ pub const DerivedConfig = struct {
arena: ArenaAllocator,
font_thicken: bool,
font_thicken_strength: u8,
font_features: std.ArrayListUnmanaged([:0]const u8),
font_styles: font.CodepointResolver.StyleStatus,
cursor_color: ?terminal.color.RGB,
@ -321,6 +322,7 @@ pub const DerivedConfig = struct {
return .{
.background_opacity = @max(0, @min(1, config.@"background-opacity")),
.font_thicken = config.@"font-thicken",
.font_thicken_strength = config.@"font-thicken-strength",
.font_features = font_features.list,
.font_styles = font_styles,
@ -764,7 +766,7 @@ pub fn updateFrame(
// We used to share terminal state, but we've since learned through
// analysis that it is faster to copy the terminal state than to
// hold the lock wile rebuilding GPU cells.
// hold the lock while rebuilding GPU cells.
var screen_copy = try state.terminal.screen.clone(
self.alloc,
.{ .viewport = .{} },
@ -2093,6 +2095,7 @@ fn addGlyph(
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
.thicken_strength = self.config.font_thicken_strength,
},
);

View File

@ -3281,7 +3281,7 @@ fn markDirty(self: *PageList, pt: point.Point) void {
/// point remains valid even through scrolling without any additional work.
///
/// A downside is that the pin is only valid until the pagelist is modified
/// in a way that may invalid page pointers or shuffle rows, such as resizing,
/// in a way that may invalidate page pointers or shuffle rows, such as resizing,
/// erasing rows, etc.
///
/// A pin can also be "tracked" which means that it will be updated as the
@ -3389,9 +3389,9 @@ pub const Pin = struct {
else => {},
}
// Never extend cell that has a default background.
// A default background is if there is no background
// on the style OR the explicitly set background
// Never extend a cell that has a default background.
// A default background is applied if there is no background
// on the style or the explicitly set background
// matches our default background.
const s = self.style(cell);
const bg = s.bg(cell, palette) orelse return true;
@ -3486,7 +3486,7 @@ pub const Pin = struct {
// If our y is after the top y but we're on the same page
// then we're between the top and bottom if our y is less
// than or equal to the bottom y IF its the same page. If the
// than or equal to the bottom y if its the same page. If the
// bottom is another page then it means that the range is
// at least the full top page and since we're the same page
// we're in the range.
@ -3508,7 +3508,7 @@ pub const Pin = struct {
if (self.y > bottom.y) return false;
if (self.y < bottom.y) return true;
// If our y is the same then we're between if we're before
// If our y is the same, then we're between if we're before
// or equal to the bottom x.
assert(self.y == bottom.y);
return self.x <= bottom.x;

View File

@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void {
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long",
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir",
error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly",
error.UnsupportedFormat => r.message = "EINVAL: unsupported format",
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",

View File

@ -220,6 +220,9 @@ pub const LoadingImage = struct {
// Temporary file logic
if (medium == .temporary_file) {
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) {
return error.TemporaryFileNotNamedCorrectly;
}
}
defer if (medium == .temporary_file) {
posix.unlink(path) catch |err| {
@ -469,6 +472,7 @@ pub const Image = struct {
DimensionsTooLarge,
FilePathTooLong,
TemporaryFileNotInTempDir,
TemporaryFileNotNamedCorrectly,
UnsupportedFormat,
UnsupportedMedium,
UnsupportedDepth,
@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
try testing.expect(img.compression == .none);
}
test "image load: rgb, not compressed, temporary file" {
test "image load: temporary file without correct path" {
const testing = std.testing;
const alloc = testing.allocator;
@ -697,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
.medium = .temporary_file,
.compression = .none,
.width = 20,
.height = 15,
.image_id = 31,
} },
.data = try alloc.dupe(u8, path),
};
defer cmd.deinit(alloc);
try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd));
// Temporary file should still be there
try tmp_dir.dir.access(path, .{});
}
test "image load: rgb, not compressed, temporary file" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp_dir = try internal_os.TempDir.init();
defer tmp_dir.deinit();
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{
.sub_path = "tty-graphics-protocol-image.data",
.data = data,
});
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" {
defer tmp_dir.deinit();
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{
.sub_path = "image.data",
.sub_path = "tty-graphics-protocol-image.data",
.data = data,
});
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath("image.data", &buf);
const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf);
var cmd: command.Command = .{
.control = .{ .transmit = .{

View File

@ -163,6 +163,15 @@ pub const Command = union(enum) {
duration_ms: u16,
},
/// Show GUI message Box (OSC 9;2)
show_message_box: []const u8,
/// Change ConEmu tab (OSC 9;3)
change_conemu_tab_title: union(enum) {
reset: void,
value: []const u8,
},
/// Set progress state (OSC 9;4)
progress: struct {
state: ProgressState,
@ -360,6 +369,9 @@ pub const Parser = struct {
// ConEmu specific substates
conemu_sleep,
conemu_sleep_value,
conemu_message_box,
conemu_tab,
conemu_tab_txt,
conemu_progress_prestate,
conemu_progress_state,
conemu_progress_prevalue,
@ -787,6 +799,12 @@ pub const Parser = struct {
'1' => {
self.state = .conemu_sleep;
},
'2' => {
self.state = .conemu_message_box;
},
'3' => {
self.state = .conemu_tab;
},
'4' => {
self.state = .conemu_progress_prestate;
},
@ -808,10 +826,38 @@ pub const Parser = struct {
else => self.state = .invalid,
},
.conemu_message_box => switch (c) {
';' => {
self.command = .{ .show_message_box = undefined };
self.temp_state = .{ .str = &self.command.show_message_box };
self.buf_start = self.buf_idx;
self.complete = true;
self.prepAllocableString();
},
else => self.state = .invalid,
},
.conemu_sleep_value => switch (c) {
else => self.complete = true,
},
.conemu_tab => switch (c) {
';' => {
self.state = .conemu_tab_txt;
self.command = .{ .change_conemu_tab_title = .{ .reset = {} } };
self.buf_start = self.buf_idx;
self.complete = true;
},
else => self.state = .invalid,
},
.conemu_tab_txt => {
self.command = .{ .change_conemu_tab_title = .{ .value = undefined } };
self.temp_state = .{ .str = &self.command.change_conemu_tab_title.value };
self.complete = true;
self.prepAllocableString();
},
.conemu_progress_prestate => switch (c) {
';' => {
self.command = .{ .progress = .{
@ -1759,6 +1805,110 @@ test "OSC: show desktop notification with title" {
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
}
test "OSC: conemu message box" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;2;hello world";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings("hello world", cmd.show_message_box);
}
test "OSC: conemu message box invalid input" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;2";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b');
try testing.expect(cmd == null);
}
test "OSC: conemu message box empty message" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;2;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings("", cmd.show_message_box);
}
test "OSC: conemu message box spaces only message" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;2; ";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings(" ", cmd.show_message_box);
}
test "OSC: conemu change tab title" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;3;foo bar";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .change_conemu_tab_title);
try testing.expectEqualStrings("foo bar", cmd.change_conemu_tab_title.value);
}
test "OSC: conemu change tab reset title" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;3;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
const expected_command: Command = .{ .change_conemu_tab_title = .{ .reset = {} } };
try testing.expectEqual(expected_command, cmd);
}
test "OSC: conemu change tab spaces only title" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;3; ";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .change_conemu_tab_title);
try testing.expectEqualStrings(" ", cmd.change_conemu_tab_title.value);
}
test "OSC: conemu change tab invalid input" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;3";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b');
try testing.expect(cmd == null);
}
test "OSC: OSC9 progress set" {
const testing = std.testing;

View File

@ -380,7 +380,8 @@ pub fn Stream(comptime Handler: type) type {
fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
switch (input.final) {
// CUU - Cursor Up
'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
'A', 'k' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -392,8 +393,15 @@ pub fn Stream(comptime Handler: type) type {
false,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI A with intermediates: {s}",
.{input.intermediates},
),
},
// CUD - Cursor Down
'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
'B' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -405,8 +413,15 @@ pub fn Stream(comptime Handler: type) type {
false,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI B with intermediates: {s}",
.{input.intermediates},
),
},
// CUF - Cursor Right
'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
'C' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -417,8 +432,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI C with intermediates: {s}",
.{input.intermediates},
),
},
// CUB - Cursor Left
'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft(
'D', 'j' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -429,8 +451,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI D with intermediates: {s}",
.{input.intermediates},
),
},
// CNL - Cursor Next Line
'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
'E' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -442,8 +471,15 @@ pub fn Stream(comptime Handler: type) type {
true,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI E with intermediates: {s}",
.{input.intermediates},
),
},
// CPL - Cursor Previous Line
'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
'F' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -455,25 +491,46 @@ pub fn Stream(comptime Handler: type) type {
true,
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI F with intermediates: {s}",
.{input.intermediates},
),
},
// HPA - Cursor Horizontal Position Absolute
// TODO: test
'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
'G', '`' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) {
0 => try self.handler.setCursorCol(1),
1 => try self.handler.setCursorCol(input.params[0]),
else => log.warn("invalid HPA command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI G with intermediates: {s}",
.{input.intermediates},
),
},
// CUP - Set Cursor Position.
// TODO: test
'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
'H', 'f' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) {
0 => try self.handler.setCursorPos(1, 1),
1 => try self.handler.setCursorPos(input.params[0], 1),
2 => try self.handler.setCursorPos(input.params[0], input.params[1]),
else => log.warn("invalid CUP command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI H with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation
'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
'I' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -484,6 +541,12 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI I with intermediates: {s}",
.{input.intermediates},
),
},
// Erase Display
'J' => if (@hasDecl(T, "eraseDisplay")) {
const protected_: ?bool = switch (input.intermediates.len) {
@ -540,22 +603,37 @@ pub fn Stream(comptime Handler: type) type {
// IL - Insert Lines
// TODO: test
'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) {
'L' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) {
0 => try self.handler.insertLines(1),
1 => try self.handler.insertLines(input.params[0]),
else => log.warn("invalid IL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI L with intermediates: {s}",
.{input.intermediates},
),
},
// DL - Delete Lines
// TODO: test
'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
'M' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) {
0 => try self.handler.deleteLines(1),
1 => try self.handler.deleteLines(input.params[0]),
else => log.warn("invalid DL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI M with intermediates: {s}",
.{input.intermediates},
),
},
// Delete Character (DCH)
'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
'P' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -566,6 +644,12 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI P with intermediates: {s}",
.{input.intermediates},
),
},
// Scroll Up (SD)
'S' => switch (input.intermediates.len) {
@ -587,7 +671,8 @@ pub fn Stream(comptime Handler: type) type {
},
// Scroll Down (SD)
'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown(
'T' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -598,27 +683,31 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
// Cursor Tabulation Control
'W' => {
switch (input.params.len) {
0 => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI T with intermediates: {s}",
.{input.intermediates},
),
},
1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') {
if (input.params[0] == 5) {
if (@hasDecl(T, "tabReset"))
try self.handler.tabReset()
else
log.warn("unimplemented tab reset callback: {}", .{input});
} else log.warn("invalid cursor tabulation control: {}", .{input});
} else {
switch (input.params[0]) {
0 => if (@hasDecl(T, "tabSet"))
// Cursor Tabulation Control
'W' => switch (input.intermediates.len) {
0 => {
if (input.params.len == 0 or
(input.params.len == 1 and input.params[0] == 0))
{
if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{input}),
log.warn("unimplemented tab set callback: {}", .{input});
return;
}
switch (input.params.len) {
0 => unreachable,
1 => switch (input.params[0]) {
0 => unreachable,
2 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.current)
@ -631,7 +720,6 @@ pub fn Stream(comptime Handler: type) type {
log.warn("unimplemented tab clear callback: {}", .{input}),
else => {},
}
},
else => {},
@ -641,8 +729,22 @@ pub fn Stream(comptime Handler: type) type {
return;
},
1 => if (input.intermediates[0] == '?' and input.params[0] == 5) {
if (@hasDecl(T, "tabReset"))
try self.handler.tabReset()
else
log.warn("unimplemented tab reset callback: {}", .{input});
} else log.warn("invalid cursor tabulation control: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI W with intermediates: {s}",
.{input.intermediates},
),
},
// Erase Characters (ECH)
'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
'X' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -653,8 +755,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI X with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation Back
'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack(
'Z' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -665,8 +774,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI Z with intermediates: {s}",
.{input.intermediates},
),
},
// HPR - Cursor Horizontal Position Relative
'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative(
'a' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -677,8 +793,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI a with intermediates: {s}",
.{input.intermediates},
),
},
// Repeat Previous Char (REP)
'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat(
'b' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -689,6 +812,12 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI b with intermediates: {s}",
.{input.intermediates},
),
},
// c - Device Attributes (DA1)
'c' => if (@hasDecl(T, "deviceAttributes")) {
const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) {
@ -708,7 +837,8 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented CSI callback: {}", .{input}),
// VPA - Cursor Vertical Position Absolute
'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow(
'd' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -719,8 +849,15 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI d with intermediates: {s}",
.{input.intermediates},
),
},
// VPR - Cursor Vertical Position Relative
'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative(
'e' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative(
switch (input.params.len) {
0 => 1,
1 => input.params[0],
@ -731,9 +868,16 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI e with intermediates: {s}",
.{input.intermediates},
),
},
// TBC - Tab Clear
// TODO: test
'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(
'g' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(
switch (input.params.len) {
1 => @enumFromInt(input.params[0]),
else => {
@ -743,6 +887,12 @@ pub fn Stream(comptime Handler: type) type {
},
) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI g with intermediates: {s}",
.{input.intermediates},
),
},
// SM - Set Mode
'h' => if (@hasDecl(T, "setMode")) mode: {
const ansi_mode = ansi: {
@ -1455,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
.progress, .sleep => {
.progress, .sleep, .show_message_box, .change_conemu_tab_title => {
log.warn("unimplemented OSC callback: {}", .{cmd});
},
}
@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented ESC callback: {}", .{action}),
// HTS - Horizontal Tab Set
'H' => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet()
else
log.warn("unimplemented tab set callback: {}", .{action}),
'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) {
0 => try self.handler.tabSet(),
else => {
log.warn("invalid tab set command: {}", .{action});
return;
},
} else log.warn("unimplemented tab set callback: {}", .{action}),
// RI - Reverse Index
'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) {
@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}),
// SPA - Start of Guarded Area
'V' => if (@hasDecl(T, "setProtectedMode")) {
'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// EPA - End of Guarded Area
'W' => if (@hasDecl(T, "setProtectedMode")) {
'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) {
try self.handler.setProtectedMode(ansi.ProtectedMode.off);
} else log.warn("unimplemented ESC callback: {}", .{action}),
// DECID
'Z' => if (@hasDecl(T, "deviceAttributes")) {
'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) {
try self.handler.deviceAttributes(.primary, &.{});
} else log.warn("unimplemented ESC callback: {}", .{action}),
@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}),
// Set application keypad mode
'=' => if (@hasDecl(T, "setMode")) {
'=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, true);
} else log.warn("unimplemented setMode: {}", .{action}),
// Reset application keypad mode
'>' => if (@hasDecl(T, "setMode")) {
'>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, false);
} else log.warn("unimplemented setMode: {}", .{action}),
@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" {
s.handler.amount = 0;
try s.nextSlice("\x1B[5;4C");
try testing.expectEqual(@as(u16, 0), s.handler.amount);
s.handler.amount = 0;
try s.nextSlice("\x1b[?3C");
try testing.expectEqual(@as(u16, 0), s.handler.amount);
}
test "stream: dec set mode (SM) and reset mode (RM)" {
@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" {
try s.nextSlice("\x1B[?6l");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
s.handler.mode = @as(modes.Mode, @enumFromInt(1));
try s.nextSlice("\x1B[6 h");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode);
}
test "stream: ansi set mode (SM) and reset mode (RM)" {
@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" {
try s.nextSlice("\x1B[4l");
try testing.expect(s.handler.mode == null);
s.handler.mode = null;
try s.nextSlice("\x1B[>5h");
try testing.expect(s.handler.mode == null);
}
test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" {
@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" {
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
{
// Invalid and ignored by the handler
for ("\x1B[>0J") |c| try s.next(c);
try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
}
test "stream: DECEL, DECSEL" {
@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" {
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
{
// Invalid and ignored by the handler
for ("\x1B[<1K") |c| try s.next(c);
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?);
}
}
test "stream: DECSCUSR" {
@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" {
try s.nextSlice("\x1B[1 q");
try testing.expect(s.handler.style.? == .blinking_block);
// Invalid and ignored by the handler
try s.nextSlice("\x1B[?0 q");
try testing.expect(s.handler.style.? == .blinking_block);
}
test "stream: DECSCUSR without space" {
@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" {
try s.nextSlice("\x1B[>1s");
try testing.expect(s.handler.escape.? == true);
// Invalid and ignored by the handler
try s.nextSlice("\x1B[1 s");
try testing.expect(s.handler.escape.? == true);
}
test "stream: change window title with invalid utf-8" {
@ -2374,6 +2559,14 @@ test "stream CSI W tab set" {
s.handler.called = false;
try s.nextSlice("\x1b[0W");
try testing.expect(s.handler.called);
s.handler.called = false;
try s.nextSlice("\x1b[>W");
try testing.expect(!s.handler.called);
s.handler.called = false;
try s.nextSlice("\x1b[99W");
try testing.expect(!s.handler.called);
}
test "stream CSI ? W reset tab stops" {
@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?5W");
try testing.expect(s.handler.reset);
// Invalid and ignored by the handler
try s.nextSlice("\x1b[?1;2;3W");
try testing.expect(s.handler.reset);
}

View File

@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
// for alt screen, we do nothing.
if (self.terminal.active_screen == .alternate) return;
// Clear our selection
self.terminal.screen.clearSelection();
// Clear our scrollback
if (history) self.terminal.eraseDisplay(.scrollback, false);

View File

@ -42,6 +42,7 @@ wdth = "wdth"
Strat = "Strat"
grey = "gray"
greyscale = "grayscale"
DECID = "DECID"
[type.swift.extend-words]
inout = "inout"