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-adwaita=${{ matrix.adwaita }} \
-Dgtk-x11=${{ matrix.x11 }} -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: test-macos:
runs-on: namespace-profile-ghostty-macos runs-on: namespace-profile-ghostty-macos
needs: test needs: test
@ -478,3 +513,38 @@ jobs:
useDaemon: false # sometimes fails on short jobs useDaemon: false # sometimes fails on short jobs
- name: typos check - name: typos check
run: nix develop -c typos 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 Version = @import("src/build/Version.zig");
const Command = @import("src/Command.zig"); const Command = @import("src/Command.zig");
const Scanner = @import("zig_wayland").Scanner;
comptime { comptime {
// This is the required Zig version for building this project. We allow // This is the required Zig version for building this project. We allow
// any patch version but the major and minor must match exactly. // 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.", "Enables the use of Adwaita when using the GTK rendering backend.",
) orelse true; ) orelse true;
config.x11 = b.option( var x11 = false;
bool, var wayland = false;
"gtk-x11",
"Enables linking against X11 libraries when using the GTK rendering backend.",
) orelse x11: {
if (target.result.os.tag != .linux) break :x11 false;
if (target.result.os.tag == .linux) pkgconfig: {
var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator); var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator);
pkgconfig.stdout_behavior = .Pipe; pkgconfig.stdout_behavior = .Pipe;
pkgconfig.stderr_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; const output_max_size = 50 * 1024;
@ -139,17 +141,44 @@ pub fn build(b: *std.Build) !void {
switch (term) { switch (term) {
.Exited => |code| { .Exited => |code| {
if (code == 0) { if (code == 0) {
if (std.mem.indexOf(u8, stdout.items, "x11")) |_| break :x11 true; if (std.mem.indexOf(u8, stdout.items, "x11")) |_| x11 = true;
break :x11 false; if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true;
} } else {
std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code });
return error.Unexpected; return error.Unexpected;
}
}, },
inline else => |code| { inline else => |code| {
std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code });
return error.Unexpected; 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( 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.", "Build a Position Independent Executable. Default true for system packages.",
) orelse system_package; ) 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 conformance = b.option(
[]const u8, []const u8,
"conformance", "conformance",
@ -342,11 +381,7 @@ pub fn build(b: *std.Build) !void {
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.strip = switch (optimize) { .strip = strip,
.Debug => false,
.ReleaseSafe => false,
.ReleaseFast, .ReleaseSmall => true,
},
}) else null; }) else null;
// Exe // 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_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_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"); 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_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_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_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"); 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"), .root_source_file = b.path("src/main_c.zig"),
.optimize = optimize, .optimize = optimize,
.target = target, .target = target,
.strip = strip,
}); });
_ = try addDeps(b, lib, config); _ = 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"), .root_source_file = b.path("src/main_c.zig"),
.optimize = optimize, .optimize = optimize,
.target = target, .target = target,
.strip = strip,
}); });
_ = try addDeps(b, lib, config); _ = try addDeps(b, lib, config);
@ -1240,13 +1282,15 @@ fn addDeps(
} }
// Sentry // Sentry
if (config.sentry) {
const sentry_dep = b.dependency("sentry", .{ const sentry_dep = b.dependency("sentry", .{
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.backend = .breakpad, .backend = .breakpad,
}); });
step.root_module.addImport("sentry", sentry_dep.module("sentry")); step.root_module.addImport("sentry", sentry_dep.module("sentry"));
if (target.result.os.tag != .windows) {
// Sentry // Sentry
step.linkLibrary(sentry_dep.artifact("sentry")); step.linkLibrary(sentry_dep.artifact("sentry"));
try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); 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.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
if (config.x11) step.linkSystemLibrary2("X11", 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"); const gresource = @import("src/apprt/gtk/gresource.zig");

View File

@ -25,6 +25,10 @@
.url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz", .url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz",
.hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25",
}, },
.zig_wayland = .{
.url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz",
.hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c",
},
// C libs // C libs
.cimgui = .{ .path = "./pkg/cimgui" }, .cimgui = .{ .path = "./pkg/cimgui" },
@ -49,8 +53,8 @@
// Other // Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" }, .apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{ .iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz",
.hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", .hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de",
}, },
.vaxis = .{ .vaxis = .{
.url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", .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", .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a",
.hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", .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 { typedef enum {
GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_PREVIOUS,
GHOSTTY_GOTO_SPLIT_NEXT, GHOSTTY_GOTO_SPLIT_NEXT,
GHOSTTY_GOTO_SPLIT_TOP, GHOSTTY_GOTO_SPLIT_UP,
GHOSTTY_GOTO_SPLIT_LEFT, GHOSTTY_GOTO_SPLIT_LEFT,
GHOSTTY_GOTO_SPLIT_BOTTOM, GHOSTTY_GOTO_SPLIT_DOWN,
GHOSTTY_GOTO_SPLIT_RIGHT, GHOSTTY_GOTO_SPLIT_RIGHT,
} ghostty_action_goto_split_e; } ghostty_action_goto_split_e;
@ -559,6 +559,7 @@ typedef struct {
// apprt.Action.Key // apprt.Action.Key
typedef enum { typedef enum {
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB, GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_NEW_SPLIT,
@ -681,10 +682,11 @@ void ghostty_config_open();
ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
ghostty_config_t); ghostty_config_t);
void ghostty_app_free(ghostty_app_t); void ghostty_app_free(ghostty_app_t);
bool ghostty_app_tick(ghostty_app_t); void ghostty_app_tick(ghostty_app_t);
void* ghostty_app_userdata(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t);
void ghostty_app_set_focus(ghostty_app_t, bool); void ghostty_app_set_focus(ghostty_app_t, bool);
bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_keyboard_changed(ghostty_app_t);
void ghostty_app_open_config(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t);
void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
@ -712,7 +714,8 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t,
ghostty_color_scheme_e); ghostty_color_scheme_e);
ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e); ghostty_input_mods_e);
void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s);
void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_captured(ghostty_surface_t);
bool ghostty_surface_mouse_button(ghostty_surface_t, bool ghostty_surface_mouse_button(ghostty_surface_t,

View File

@ -10,8 +10,8 @@
29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; };
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@ -71,6 +71,7 @@
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
@ -87,6 +88,8 @@
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; };
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; };
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; };
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; };
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; };
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; };
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
@ -108,8 +111,8 @@
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = "<group>"; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; };
@ -162,6 +165,7 @@
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = "<group>"; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = "<group>"; };
@ -177,6 +181,8 @@
A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; };
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; };
A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = "<group>"; };
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Event.swift; sourceTree = "<group>"; };
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = "<group>"; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = "<group>"; };
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = "<group>"; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = "<group>"; };
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -263,6 +269,7 @@
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
@ -351,12 +358,14 @@
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */, A55685DF29A03A9F004303CE /* AppError.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
); );
path = Ghostty; path = Ghostty;
sourceTree = "<group>"; sourceTree = "<group>";
@ -399,13 +408,13 @@
children = ( children = (
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */, FC9ABA9B2D0F538D0020D4C8 /* bash-completion */,
29C15B1C2CDC3B2000520DD4 /* bat */, 29C15B1C2CDC3B2000520DD4 /* bat */,
55154BDF2B33911F001622DC /* ghostty */,
552964E52B34A9B400030505 /* vim */,
A586167B2B7703CC009BDB1D /* fish */, A586167B2B7703CC009BDB1D /* fish */,
55154BDF2B33911F001622DC /* ghostty */,
A5985CE52C33060F00C57AD3 /* man */, A5985CE52C33060F00C57AD3 /* man */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
FC5218F92D10FFC7004C93E0 /* zsh */,
9351BE8E2D22937F003B3499 /* nvim */, 9351BE8E2D22937F003B3499 /* nvim */,
A5A1F8842A489D6800D1E8BC /* terminfo */,
552964E52B34A9B400030505 /* vim */,
FC5218F92D10FFC7004C93E0 /* zsh */,
); );
name = Resources; name = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -611,12 +620,14 @@
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
@ -647,6 +658,7 @@
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */,

View File

@ -358,8 +358,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
@ -425,6 +425,15 @@ class AppDelegate: NSObject,
// because we let it capture and propagate. // because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event } guard NSApp.mainWindow == nil else { return event }
// If this event as-is would result in a key binding then we send it.
if let app = ghostty.app,
ghostty_app_key_is_binding(
app,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))
return nil
}
// If this event would be handled by our menu then we do nothing. // If this event would be handled by our menu then we do nothing.
if let mainMenu = NSApp.mainMenu, if let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) { mainMenu.performKeyEquivalent(with: event) {
@ -438,13 +447,7 @@ class AppDelegate: NSObject,
guard let ghostty = self.ghostty.app else { return event } guard let ghostty = self.ghostty.app else { return event }
// Build our event input and call ghostty // Build our event input and call ghostty
var key_ev = ghostty_input_key_s() if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
key_ev.action = GHOSTTY_ACTION_PRESS
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
if (ghostty_app_key(ghostty, key_ev)) {
// The key was used so we want to stop it from going to our Mac app // The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event)") Ghostty.logger.debug("local key event handled event=\(event)")
return nil return nil
@ -486,15 +489,16 @@ class AppDelegate: NSObject,
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior // explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration. // defined by our "auto-update" configuration (if set) or fall back to Sparkle
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false { // user-based defaults.
updaterController.updater.automaticallyChecksForUpdates = if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
config.autoUpdate == .check || config.autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
config.autoUpdate == .download
} else {
updaterController.updater.automaticallyChecksForUpdates = false updaterController.updater.automaticallyChecksForUpdates = false
updaterController.updater.automaticallyDownloadsUpdates = 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 // 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) return .init(x: screen.frame.minX, y: -window.frame.height)
case .left: case .left:
return .init(x: -window.frame.width, y: 0) return .init(x: screen.frame.minX-window.frame.width, y: 0)
case .right: case .right:
return .init(x: screen.frame.maxX, y: 0) return .init(x: screen.frame.maxX, y: 0)
case .center: case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: screen.visibleFrame.maxY - window.frame.width) return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.height - window.frame.width)
} }
} }
@ -115,7 +115,7 @@ enum QuickTerminalPosition : String {
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y)
case .center: case .center:
return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: (screen.visibleFrame.maxY - window.frame.height) / 2) return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
} }
} }
} }

View File

@ -540,11 +540,11 @@ class BaseTerminalController: NSWindowController,
} }
@IBAction func splitMoveFocusAbove(_ sender: Any) { @IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top) splitMoveFocus(direction: .up)
} }
@IBAction func splitMoveFocusBelow(_ sender: Any) { @IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom) splitMoveFocus(direction: .down)
} }
@IBAction func splitMoveFocusLeft(_ sender: Any) { @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 // When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not. // properties change when fullscreen or not.
guard let focusedSurface else { return } guard let focusedSurface else { return }
if (!(fullscreenStyle?.isFullscreen ?? false) &&
ghostty.config.macosTitlebarStyle == "hidden")
{
applyHiddenTitlebarStyle()
}
syncAppearance(focusedSurface.derivedConfig) syncAppearance(focusedSurface.derivedConfig)
} }
@ -244,7 +250,9 @@ class TerminalController: BaseTerminalController {
let backgroundColor: OSColor let backgroundColor: OSColor
if let surfaceTree { if let surfaceTree {
if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { 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 { } else {
// We don't have a focused surface or our surface doesn't border the // 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. // 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 //MARK: - NSWindowController
override func windowWillLoad() { override func windowWillLoad() {
@ -274,6 +304,43 @@ class TerminalController: BaseTerminalController {
shouldCascadeWindows = false 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() { override func windowDidLoad() {
super.windowDidLoad() super.windowDidLoad()
guard let window = window as? TerminalWindow else { return } 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 // Set our window positioning to coordinates if config value exists, otherwise
// when cascading. // fallback to original centering behavior
window.center() setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY,
windowDecorations: config.windowDecorations)
// Make sure our theme is set on the window so styling is correct. // Make sure our theme is set on the window so styling is correct.
if let windowTheme = config.windowTheme { if let windowTheme = config.windowTheme {
@ -365,38 +435,7 @@ class TerminalController: BaseTerminalController {
// If our titlebar style is "hidden" we adjust the style appropriately // If our titlebar style is "hidden" we adjust the style appropriately
if (config.macosTitlebarStyle == "hidden") { if (config.macosTitlebarStyle == "hidden") {
window.styleMask = [ applyHiddenTitlebarStyle()
// 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
}
} }
// In various situations, macOS automatically tabs new windows. Ghostty handles // In various situations, macOS automatically tabs new windows. Ghostty handles

View File

@ -117,23 +117,7 @@ extension Ghostty {
func appTick() { func appTick() {
guard let app = self.app else { return } guard let app = self.app else { return }
ghostty_app_tick(app)
// Tick our app, which lets us know if we want to quit
let exit = ghostty_app_tick(app)
if (!exit) { return }
// On iOS, applications do not terminate programmatically like they do
// on macOS. On iOS, applications are only terminated when a user physically
// closes the application (i.e. going to the home screen). If we request
// exit on iOS we ignore it.
#if os(iOS)
logger.info("quit request received, ignoring on iOS")
#endif
#if os(macOS)
// We want to quit, start that process
NSApplication.shared.terminate(nil)
#endif
} }
func openConfig() { func openConfig() {
@ -454,6 +438,9 @@ extension Ghostty {
// Action dispatch // Action dispatch
switch (action.tag) { switch (action.tag) {
case GHOSTTY_ACTION_QUIT:
quit(app)
case GHOSTTY_ACTION_NEW_WINDOW: case GHOSTTY_ACTION_NEW_WINDOW:
newWindow(app, target: target) newWindow(app, target: target)
@ -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) { private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) { switch (target.tag) {
case GHOSTTY_TARGET_APP: case GHOSTTY_TARGET_APP:

View File

@ -150,6 +150,20 @@ extension Ghostty {
return String(cString: ptr) 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 { var windowNewTabPosition: String {
guard let config = self.config else { return "" } guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
@ -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 { var splitDividerColor: Color {
let backgroundColor = OSColor(backgroundColor) let backgroundColor = OSColor(backgroundColor)
let isLightBackground = backgroundColor.isLightColor let isLightBackground = backgroundColor.isLightColor
let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4)
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 Color(newColor)
} }
return .init(
red: Double(color.r) / 255,
green: Double(color.g) / 255,
blue: Double(color.b) / 255
)
}
#if canImport(AppKit) #if canImport(AppKit)
var quickTerminalPosition: QuickTerminalPosition { var quickTerminalPosition: QuickTerminalPosition {
guard let config = self.config else { return .top } guard let config = self.config else { return .top }
@ -437,15 +462,14 @@ extension Ghostty {
return v; return v;
} }
var autoUpdate: AutoUpdate { var autoUpdate: AutoUpdate? {
let defaultValue = AutoUpdate.check guard let config = self.config else { return nil }
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = nil var v: UnsafePointer<Int8>? = nil
let key = "auto-update" let key = "auto-update"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
guard let ptr = v else { return defaultValue } guard let ptr = v else { return nil }
let str = String(cString: ptr) let str = String(cString: ptr)
return AutoUpdate(rawValue: str) ?? defaultValue return AutoUpdate(rawValue: str)
} }
var autoUpdateChannel: AutoUpdateChannel { 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 /// 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 /// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to. /// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
let container: Container let container: Container
switch (self) { switch (self) {
case .leaf(let leaf): case .leaf(let leaf):
@ -64,10 +64,10 @@ extension Ghostty {
let node: SplitNode let node: SplitNode
switch (direction) { switch (direction) {
case .previous, .top, .left: case .previous, .up, .left:
node = container.bottomRight node = container.bottomRight
case .next, .bottom, .right: case .next, .down, .right:
node = container.topLeft node = container.topLeft
} }
@ -431,12 +431,12 @@ extension Ghostty {
struct Neighbors { struct Neighbors {
var left: SplitNode? var left: SplitNode?
var right: SplitNode? var right: SplitNode?
var top: SplitNode? var up: SplitNode?
var bottom: SplitNode? var down: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well /// 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 /// 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 previous: SplitNode?
var next: SplitNode? var next: SplitNode?
@ -448,8 +448,8 @@ extension Ghostty {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [ let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous, .previous: \.previous,
.next: \.next, .next: \.next,
.top: \.top, .up: \.up,
.bottom: \.bottom, .down: \.down,
.left: \.left, .left: \.left,
.right: \.right, .right: \.right,
] ]

View File

@ -308,7 +308,7 @@ extension Ghostty {
resizeIncrements: .init(width: 1, height: 1), resizeIncrements: .init(width: 1, height: 1),
resizePublisher: container.resizeEvent, resizePublisher: container.resizeEvent,
left: { left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.bottom let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
TerminalSplitNested( TerminalSplitNested(
node: closeableTopLeft(), node: closeableTopLeft(),
@ -318,7 +318,7 @@ extension Ghostty {
]) ])
) )
}, right: { }, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.top let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
TerminalSplitNested( TerminalSplitNested(
node: closeableBottomRight(), 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. /// An enum that is used for the directions that a split focus event can change.
enum SplitFocusDirection { enum SplitFocusDirection {
case previous, next, top, bottom, left, right case previous, next, up, down, left, right
/// Initialize from a Ghostty API enum. /// Initialize from a Ghostty API enum.
static func from(direction: ghostty_action_goto_split_e) -> Self? { static func from(direction: ghostty_action_goto_split_e) -> Self? {
@ -77,11 +77,11 @@ extension Ghostty {
case GHOSTTY_GOTO_SPLIT_NEXT: case GHOSTTY_GOTO_SPLIT_NEXT:
return .next return .next
case GHOSTTY_GOTO_SPLIT_TOP: case GHOSTTY_GOTO_SPLIT_UP:
return .top return .up
case GHOSTTY_GOTO_SPLIT_BOTTOM: case GHOSTTY_GOTO_SPLIT_DOWN:
return .bottom return .down
case GHOSTTY_GOTO_SPLIT_LEFT: case GHOSTTY_GOTO_SPLIT_LEFT:
return .left return .left
@ -102,11 +102,11 @@ extension Ghostty {
case .next: case .next:
return GHOSTTY_GOTO_SPLIT_NEXT return GHOSTTY_GOTO_SPLIT_NEXT
case .top: case .up:
return GHOSTTY_GOTO_SPLIT_TOP return GHOSTTY_GOTO_SPLIT_UP
case .bottom: case .down:
return GHOSTTY_GOTO_SPLIT_BOTTOM return GHOSTTY_GOTO_SPLIT_DOWN
case .left: case .left:
return GHOSTTY_GOTO_SPLIT_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 // A small delay that is introduced before a title change to avoid flickers
private var titleChangeTimer: Timer? 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 // We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true } override var acceptsFirstResponder: Bool { return true }
@ -170,6 +173,15 @@ extension Ghostty {
name: NSWindow.didChangeScreenNotification, name: NSWindow.didChangeScreenNotification,
object: nil) object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [
// We need keyUp because command+key events don't trigger keyUp.
.keyUp
]
) { [weak self] event in self?.localEventHandler(event) }
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
@ -212,6 +224,11 @@ extension Ghostty {
let center = NotificationCenter.default let center = NotificationCenter.default
center.removeObserver(self) center.removeObserver(self)
// Remove our event monitor
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
// Whenever the surface is removed, we need to note that our restorable // Whenever the surface is removed, we need to note that our restorable
// state is invalid to prevent the surface from being restored. // state is invalid to prevent the surface from being restored.
invalidateRestorableState() invalidateRestorableState()
@ -356,6 +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 // MARK: - Notifications
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
@ -764,8 +805,23 @@ extension Ghostty {
// know if these events cleared it. // know if these events cleared it.
let markedTextBefore = markedText.length > 0 let markedTextBefore = markedText.length > 0
// We need to know the keyboard layout before below because some keyboard
// input events will change our keyboard layout and we don't want those
// going to the terminal.
let keyboardIdBefore: String? = if (!markedTextBefore) {
KeyboardLayout.id
} else {
nil
}
self.interpretKeyEvents([translationEvent]) self.interpretKeyEvents([translationEvent])
// If our keyboard changed from this we just assume an input method
// grabbed it and do nothing.
if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) {
return
}
// If we have text, then we've composed a character, send that down. We do this // If we have text, then we've composed a character, send that down. We do this
// first because if we completed a preedit, the text will be available here // first because if we completed a preedit, the text will be available here
// AND we'll have a preedit. // AND we'll have a preedit.
@ -773,7 +829,7 @@ extension Ghostty {
if let list = keyTextAccumulator, list.count > 0 { if let list = keyTextAccumulator, list.count > 0 {
handled = true handled = true
for text in list { for text in list {
keyAction(action, event: event, text: text) _ = keyAction(action, event: event, text: text)
} }
} }
@ -783,38 +839,49 @@ extension Ghostty {
// the preedit. // the preedit.
if (markedText.length > 0 || markedTextBefore) { if (markedText.length > 0 || markedTextBefore) {
handled = true handled = true
keyAction(action, event: event, preedit: markedText.string) _ = keyAction(action, event: event, preedit: markedText.string)
} }
if (!handled) { if (!handled) {
// No text or anything, we want to handle this manually. // No text or anything, we want to handle this manually.
keyAction(action, event: event) _ = keyAction(action, event: event)
} }
} }
override func keyUp(with event: NSEvent) { override func keyUp(with event: NSEvent) {
keyAction(GHOSTTY_ACTION_RELEASE, event: event) _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event)
} }
/// Special case handling for some control keys /// Special case handling for some control keys
override func performKeyEquivalent(with event: NSEvent) -> Bool { override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Only process key down events switch (event.type) {
if (event.type != .keyDown) { case .keyDown:
// Continue, we care about key down events
break
default:
// Any other key event we don't care about. I don't think its even
// possible to receive any other event type.
return false return false
} }
// Only process events if we're focused. Some key events like C-/ macOS // Only process events if we're focused. Some key events like C-/ macOS
// appears to send to the first view in the hierarchy rather than the // appears to send to the first view in the hierarchy rather than the
// the first responder (I don't know why). This prevents us from handling it. // the first responder (I don't know why). This prevents us from handling it.
// Besides C-/, its important we don't process key equivalents if unfocused
// because there are other event listeners for that (i.e. AppDelegate's
// local event handler).
if (!focused) { if (!focused) {
return false return false
} }
// Only process keys when Control is active. All known issues we're // If this event as-is would result in a key binding then we send it.
// resolving happen only in this scenario. This probably isn't fully robust if let surface,
// but we can broaden the scope as we find more cases. ghostty_surface_key_is_binding(
if (!event.modifierFlags.contains(.control)) { surface,
return false event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
self.keyDown(with: event)
return true
} }
let equivalent: String let equivalent: String
@ -832,14 +899,25 @@ extension Ghostty {
case "\r": case "\r":
// Pass C-<return> through verbatim // Pass C-<return> through verbatim
// (prevent the default context menu equivalent) // (prevent the default context menu equivalent)
if (!event.modifierFlags.contains(.control)) {
return false
}
equivalent = "\r" equivalent = "\r"
case ".":
if (!event.modifierFlags.contains(.command)) {
return false
}
equivalent = "."
default: default:
// Ignore other events // Ignore other events
return false return false
} }
let newEvent = NSEvent.keyEvent( let finalEvent = NSEvent.keyEvent(
with: .keyDown, with: .keyDown,
location: event.locationInWindow, location: event.locationInWindow,
modifierFlags: event.modifierFlags, modifierFlags: event.modifierFlags,
@ -852,7 +930,7 @@ extension Ghostty {
keyCode: event.keyCode keyCode: event.keyCode
) )
self.keyDown(with: newEvent!) self.keyDown(with: finalEvent!)
return true 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) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool {
guard let surface = self.surface else { return } guard let surface = self.surface else { return false }
return ghostty_surface_key(surface, event.ghosttyKeyEvent(action))
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
ghostty_surface_key(surface, key_ev)
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { private func keyAction(
guard let surface = self.surface else { return } _ action: ghostty_input_action_e,
event: NSEvent, preedit: String
) -> Bool {
guard let surface = self.surface else { return false }
preedit.withCString { ptr in return preedit.withCString { ptr in
var key_ev = ghostty_input_key_s() var key_ev = event.ghosttyKeyEvent(action)
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr key_ev.text = ptr
key_ev.composing = true key_ev.composing = true
ghostty_surface_key(surface, key_ev) return ghostty_surface_key(surface, key_ev)
} }
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { private func keyAction(
guard let surface = self.surface else { return } _ action: ghostty_input_action_e,
event: NSEvent, text: String
) -> Bool {
guard let surface = self.surface else { return false }
text.withCString { ptr in return text.withCString { ptr in
var key_ev = ghostty_input_key_s() var key_ev = event.ghosttyKeyEvent(action)
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = ptr key_ev.text = ptr
ghostty_surface_key(surface, key_ev) return ghostty_surface_key(surface, key_ev)
} }
} }

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
.{ .{
.name = "cimgui", .name = "cimgui",
.version = "1.89.9", .version = "1.90.6", // -docking branch
.paths = .{""}, .paths = .{""},
.dependencies = .{ .dependencies = .{
// This should be kept in sync with the submodule in the cimgui source // 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 = .{ .imgui = .{
.url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz", .url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz",
.hash = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402", .hash = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402",

View File

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

View File

@ -5,7 +5,61 @@ pub fn build(b: *std.Build) !void {
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false; const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false;
const module = b.addModule("freetype", .{ .root_source_file = b.path("main.zig") }); const module = b.addModule("freetype", .{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
// For dynamic linking, we prefer dynamic linking and to search by
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
var test_exe: ?*std.Build.Step.Compile = null;
if (target.query.isNative()) {
test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
const tests_run = b.addRunArtifact(test_exe.?);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
module.addIncludePath(b.path(""));
if (b.systemIntegrationOption("freetype", .{})) {
module.linkSystemLibrary("freetype2", dynamic_link_opts);
if (test_exe) |exe| {
exe.linkSystemLibrary2("freetype2", dynamic_link_opts);
}
} else {
const lib = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
.libpng_enabled = libpng_enabled,
.dynamic_link_opts = dynamic_link_opts,
});
if (test_exe) |exe| {
exe.linkLibrary(lib);
}
}
}
fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
const target = options.target;
const optimize = options.optimize;
const libpng_enabled = options.libpng_enabled;
const upstream = b.dependency("freetype", .{}); const upstream = b.dependency("freetype", .{});
const lib = b.addStaticLibrary(.{ const lib = b.addStaticLibrary(.{
@ -21,16 +75,6 @@ pub fn build(b: *std.Build) !void {
} }
module.addIncludePath(upstream.path("include")); module.addIncludePath(upstream.path("include"));
module.addIncludePath(b.path(""));
// For dynamic linking, we prefer dynamic linking and to search by
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
var flags = std.ArrayList([]const u8).init(b.allocator); var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit(); defer flags.deinit();
try flags.appendSlice(&.{ try flags.appendSlice(&.{
@ -44,6 +88,8 @@ pub fn build(b: *std.Build) !void {
"-fno-sanitize=undefined", "-fno-sanitize=undefined",
}); });
const dynamic_link_opts = options.dynamic_link_opts;
// Zlib // Zlib
if (b.systemIntegrationOption("zlib", .{})) { if (b.systemIntegrationOption("zlib", .{})) {
lib.linkSystemLibrary2("zlib", dynamic_link_opts); lib.linkSystemLibrary2("zlib", dynamic_link_opts);
@ -113,18 +159,7 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib); b.installArtifact(lib);
if (target.query.isNative()) { return lib;
const test_exe = b.addTest(.{
.name = "test",
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
});
test_exe.linkLibrary(lib);
const tests_run = b.addRunArtifact(test_exe);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step);
}
} }
const srcs: []const []const u8 = &.{ const srcs: []const []const u8 = &.{

View File

@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void {
.@"enable-libpng" = true, .@"enable-libpng" = true,
}); });
const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize });
const upstream = b.dependency("harfbuzz", .{});
const module = b.addModule("harfbuzz", .{ const module = b.addModule("harfbuzz", .{
.root_source_file = b.path("main.zig"), .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(.{ const lib = b.addStaticLibrary(.{
.name = "harfbuzz", .name = "harfbuzz",
.target = target, .target = target,
@ -41,13 +100,7 @@ pub fn build(b: *std.Build) !void {
try apple_sdk.addPaths(b, module); try apple_sdk.addPaths(b, module);
} }
// For dynamic linking, we prefer dynamic linking and to search by const dynamic_link_opts = options.dynamic_link_opts;
// mode first. Mode first will search all paths for a dynamic library
// before falling back to static.
const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
.preferred_link_mode = .dynamic,
.search_strategy = .mode_first,
};
var flags = std.ArrayList([]const u8).init(b.allocator); var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit(); defer flags.deinit();
@ -102,20 +155,5 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(lib); b.installArtifact(lib);
{ return 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);
}
} }

View File

@ -5,36 +5,59 @@ pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); 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", .{}); // For dynamic linking, we prefer dynamic linking and to search by
const lib = try buildOniguruma(b, upstream, target, optimize); // mode first. Mode first will search all paths for a dynamic library
module.addIncludePath(upstream.path("src")); // before falling back to static.
b.installArtifact(lib); 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()) { if (target.query.isNative()) {
const test_exe = b.addTest(.{ test_exe = b.addTest(.{
.name = "test", .name = "test",
.root_source_file = b.path("main.zig"), .root_source_file = b.path("main.zig"),
.target = target, .target = target,
.optimize = optimize, .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"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&tests_run.step); test_step.dependOn(&tests_run.step);
// Uncomment this if we're debugging tests // 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( fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
b: *std.Build, const target = options.target;
upstream: *std.Build.Dependency, const optimize = options.optimize;
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode, const upstream = b.dependency("oniguruma", .{});
) !*std.Build.Step.Compile {
const lib = b.addStaticLibrary(.{ const lib = b.addStaticLibrary(.{
.name = "oniguruma", .name = "oniguruma",
.target = target, .target = target,
@ -43,6 +66,7 @@ fn buildOniguruma(
const t = target.result; const t = target.result;
lib.linkLibC(); lib.linkLibC();
lib.addIncludePath(upstream.path("src")); lib.addIncludePath(upstream.path("src"));
module.addIncludePath(upstream.path("src"));
if (target.result.isDarwin()) { if (target.result.isDarwin()) {
const apple_sdk = @import("apple_sdk"); const apple_sdk = @import("apple_sdk");
@ -134,5 +158,7 @@ fn buildOniguruma(
.{ .include_extensions = &.{".h"} }, .{ .include_extensions = &.{".h"} },
); );
b.installArtifact(lib);
return lib; return lib;
} }

View File

@ -1,6 +1,6 @@
.{ .{
.name = "simdutf", .name = "simdutf",
.version = "4.0.9", .version = "5.2.8",
.paths = .{""}, .paths = .{""},
.dependencies = .{ .dependencies = .{
.apple_sdk = .{ .path = "../apple-sdk" }, .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"), .file = wuffs.path("release/c/wuffs-v0.4.c"),
.flags = flags.items, .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", .version = "0.0.0",
.dependencies = .{ .dependencies = .{
.wuffs = .{ .wuffs = .{
.url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz", .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz",
.hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462", .hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd",
},
.pixels = .{
.url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877",
.hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806",
}, },
.apple_sdk = .{ .path = "../apple-sdk" }, .apple_sdk = .{ .path = "../apple-sdk" },

View File

@ -1,3 +1,13 @@
const std = @import("std"); const std = @import("std");
const c = @import("c.zig").c;
pub const Error = std.mem.Allocator.Error || error{WuffsError}; 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 png = @import("png.zig");
pub const jpeg = @import("jpeg.zig");
pub const swizzle = @import("swizzle.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 Allocator = std.mem.Allocator;
const c = @import("c.zig").c; const c = @import("c.zig").c;
const Error = @import("error.zig").Error; 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); const log = std.log.scoped(.wuffs_png);
/// Decode a PNG image. /// Decode a PNG image.
pub fn decode(alloc: Allocator, data: []const u8) Error!struct { pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData {
width: u32,
height: u32,
data: []const u8,
} {
// Work around some weirdness in WUFFS/Zig, there are some structs that // 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 // 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 // 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, c.WUFFS_VERSION,
0, 0,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
var source_buffer: c.wuffs_base__io_buffer = .{ var source_buffer: c.wuffs_base__io_buffer = .{
@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
&image_config, &image_config,
&source_buffer, &source_buffer,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); 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, &image_config.pixcfg,
c.wuffs_base__make_slice_u8(destination.ptr, destination.len), c.wuffs_base__make_slice_u8(destination.ptr, destination.len),
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
var frame_config: c.wuffs_base__frame_config = undefined; 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, &frame_config,
&source_buffer, &source_buffer,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
{ {
@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
work_slice, work_slice,
null, null,
); );
if (!c.wuffs_base__status__is_ok(&status)) { try check(log, &status);
const e = c.wuffs_base__status__message(&status);
log.warn("decode err={s}", .{e});
return error.WuffsError;
}
} }
return .{ return .{
@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct {
.data = destination, .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). /// this is a blocking queue so if it is full you will get errors (or block).
mailbox: Mailbox.Queue, mailbox: Mailbox.Queue,
/// Set to true once we're quitting. This never goes false again.
quit: bool,
/// The set of font GroupCache instances shared by surfaces with the /// The set of font GroupCache instances shared by surfaces with the
/// same font configuration. /// same font configuration.
font_grid_set: font.SharedGridSet, font_grid_set: font.SharedGridSet,
@ -98,7 +95,6 @@ pub fn create(
.alloc = alloc, .alloc = alloc,
.surfaces = .{}, .surfaces = .{},
.mailbox = .{}, .mailbox = .{},
.quit = false,
.font_grid_set = font_grid_set, .font_grid_set = font_grid_set,
.config_conditional_state = .{}, .config_conditional_state = .{},
}; };
@ -125,9 +121,7 @@ pub fn destroy(self: *App) void {
/// Tick ticks the app loop. This will drain our mailbox and process those /// Tick ticks the app loop. This will drain our mailbox and process those
/// events. This should be called by the application runtime on every loop /// events. This should be called by the application runtime on every loop
/// tick. /// tick.
/// pub fn tick(self: *App, rt_app: *apprt.App) !void {
/// This returns whether the app should quit or not.
pub fn tick(self: *App, rt_app: *apprt.App) !bool {
// If any surfaces are closing, destroy them // If any surfaces are closing, destroy them
var i: usize = 0; var i: usize = 0;
while (i < self.surfaces.items.len) { while (i < self.surfaces.items.len) {
@ -142,13 +136,6 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool {
// Drain our mailbox // Drain our mailbox
try self.drainMailbox(rt_app); try self.drainMailbox(rt_app);
// No matter what, we reset the quit flag after a tick. If the apprt
// doesn't want to quit, then we can't force it to.
defer self.quit = false;
// We quit if our quit flag is on
return self.quit;
} }
/// Update the configuration associated with the app. This can only be /// Update the configuration associated with the app. This can only be
@ -272,7 +259,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
// can try to quit as quickly as possible. // can try to quit as quickly as possible.
.quit => { .quit => {
log.info("quit message received, short circuiting mailbox drain", .{}); log.info("quit message received, short circuiting mailbox drain", .{});
self.setQuit(); try self.performAction(rt_app, .quit);
return; return;
}, },
} }
@ -314,12 +301,6 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
); );
} }
/// Start quitting
pub fn setQuit(self: *App) void {
if (self.quit) return;
self.quit = true;
}
/// Handle an app-level focus event. This should be called whenever /// Handle an app-level focus event. This should be called whenever
/// the focus state of the entire app containing Ghostty changes. /// the focus state of the entire app containing Ghostty changes.
/// This is separate from surface focus events. See the `focused` /// This is separate from surface focus events. See the `focused`
@ -332,6 +313,25 @@ pub fn focusEvent(self: *App, focused: bool) void {
self.focused = focused; self.focused = focused;
} }
/// Returns true if the given key event would trigger a keybinding
/// if it were to be processed. This is useful for determining if
/// a key event should be sent to the terminal or not.
pub fn keyEventIsBinding(
self: *App,
rt_app: *apprt.App,
event: input.KeyEvent,
) bool {
_ = self;
switch (event.action) {
.release => return false,
.press, .repeat => {},
}
// If we have a keybinding for this event then we return true.
return rt_app.config.keybind.set.getEvent(event) != null;
}
/// Handle a key event at the app-scope. If this key event is used, /// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing /// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false. /// the event. If the event is not used, this will return false.
@ -437,7 +437,7 @@ pub fn performAction(
switch (action) { switch (action) {
.unbind => unreachable, .unbind => unreachable,
.ignore => {}, .ignore => {},
.quit => self.setQuit(), .quit => try rt_app.performAction(.app, .quit, {}),
.new_window => try self.newWindow(rt_app, .{ .parent = null }), .new_window => try self.newWindow(rt_app, .{ .parent = null }),
.open_config => try rt_app.performAction(.app, .open_config, {}), .open_config => try rt_app.performAction(.app, .open_config, {}),
.reload_config => try rt_app.performAction(.app, .reload_config, .{}), .reload_config => try rt_app.performAction(.app, .reload_config, .{}),

View File

@ -18,6 +18,7 @@ const Command = @This();
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const global_state = &@import("global.zig").state;
const internal_os = @import("os/main.zig"); const internal_os = @import("os/main.zig");
const windows = internal_os.windows; const windows = internal_os.windows;
const TempDir = internal_os.TempDir; 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. // 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 the user requested a pre exec callback, call it now.
if (self.pre_exec) |f| f(self); 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. // If we are in the middle of a key sequence, clear it.
self.keyboard.bindings = null;
self.endKeySequence(.drop, .free); self.endKeySequence(.drop, .free);
// Before sending any other config changes, we give the renderer a new font // 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(); try self.queueRender();
} }
/// Returns true if the given key event would trigger a keybinding
/// if it were to be processed. This is useful for determining if
/// a key event should be sent to the terminal or not.
///
/// Note that this function does not check if the binding itself
/// is performable, only if the key event would trigger a binding.
/// If a performable binding is found and the event is not performable,
/// then Ghosty will act as though the binding does not exist.
pub fn keyEventIsBinding(
self: *Surface,
event: input.KeyEvent,
) bool {
switch (event.action) {
.release => return false,
.press, .repeat => {},
}
// Our keybinding set is either our current nested set (for
// sequences) or the root set.
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// If we have a keybinding for this event then we return true.
return set.getEvent(event) != null;
}
/// Called for any key events. This handles keybindings, encoding and /// Called for any key events. This handles keybindings, encoding and
/// sending to the terminal, etc. /// sending to the terminal, etc.
pub fn keyCallback( pub fn keyCallback(
@ -1853,9 +1877,6 @@ fn maybeHandleBinding(
if (self.keyboard.bindings != null and if (self.keyboard.bindings != null and
!event.key.modifier()) !event.key.modifier())
{ {
// Reset to the root set
self.keyboard.bindings = null;
// Encode everything up to this point // Encode everything up to this point
self.endKeySequence(.flush, .retain); self.endKeySequence(.flush, .retain);
} }
@ -1941,10 +1962,21 @@ fn maybeHandleBinding(
return .closed; 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 // 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 // it, we processed the action but we still want to process our
// encodings, too. // encodings, too.
if (performed and consumed) { if (consumed) {
// If we had queued events, we deinit them since we consumed // If we had queued events, we deinit them since we consumed
self.endKeySequence(.drop, .retain); 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) { if (self.keyboard.queued.items.len > 0) {
switch (action) { switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| { .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}); log.err("error setting clipboard string err={}", .{err});
return true; return true;
}; };
return true;
} }
return false;
}, },
.paste_from_clipboard => try self.startClipboardRequest( .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 // entry. If the value type is void then only the key needs to be
// added. Ensure the order matches exactly with the Zig code. // added. Ensure the order matches exactly with the Zig code.
/// Quit the application.
quit,
/// Open a new window. The target determines whether properties such /// Open a new window. The target determines whether properties such
/// as font size should be inherited. /// as font size should be inherited.
new_window, new_window,
@ -219,6 +222,7 @@ pub const Action = union(Key) {
/// Sync with: ghostty_action_tag_e /// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) { pub const Key = enum(c_int) {
quit,
new_window, new_window,
new_tab, new_tab,
new_split, new_split,
@ -332,9 +336,9 @@ pub const GotoSplit = enum(c_int) {
previous, previous,
next, next,
top, up,
left, left,
bottom, down,
right, right,
}; };

View File

@ -147,12 +147,12 @@ pub const App = struct {
self.core_app.focusEvent(focused); self.core_app.focusEvent(focused);
} }
/// See CoreApp.keyEvent. /// Convert a C key event into a Zig key event.
pub fn keyEvent( fn coreKeyEvent(
self: *App, self: *App,
target: KeyTarget, target: KeyTarget,
event: KeyEvent, event: KeyEvent,
) !bool { ) !?input.KeyEvent {
const action = event.action; const action = event.action;
const keycode = event.keycode; const keycode = event.keycode;
const mods = event.mods; const mods = event.mods;
@ -243,7 +243,7 @@ pub const App = struct {
result.text, result.text,
) catch |err| { ) catch |err| {
log.err("error in preedit callback err={}", .{err}); log.err("error in preedit callback err={}", .{err});
return false; return null;
}, },
} }
} else { } else {
@ -251,7 +251,7 @@ pub const App = struct {
.app => {}, .app => {},
.surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err}); log.err("error in preedit callback err={}", .{err});
return false; return null;
}, },
} }
@ -335,7 +335,7 @@ pub const App = struct {
} else .invalid; } else .invalid;
// Build our final key event // Build our final key event
const input_event: input.KeyEvent = .{ return .{
.action = action, .action = action,
.key = key, .key = key,
.physical_key = physical_key, .physical_key = physical_key,
@ -345,24 +345,39 @@ pub const App = struct {
.utf8 = result.text, .utf8 = result.text,
.unshifted_codepoint = unshifted_codepoint, .unshifted_codepoint = unshifted_codepoint,
}; };
}
/// See CoreApp.keyEvent.
pub fn keyEvent(
self: *App,
target: KeyTarget,
event: KeyEvent,
) !bool {
// Convert our C key event into a Zig one.
const input_event: input.KeyEvent = (try self.coreKeyEvent(
target,
event,
)) orelse return false;
// Invoke the core Ghostty logic to handle this input. // Invoke the core Ghostty logic to handle this input.
const effect: CoreSurface.InputEffect = switch (target) { const effect: CoreSurface.InputEffect = switch (target) {
.app => if (self.core_app.keyEvent( .app => if (self.core_app.keyEvent(
self, self,
input_event, input_event,
)) )) .consumed else .ignored,
.consumed
else
.ignored,
.surface => |surface| try surface.core_surface.keyCallback(input_event), .surface => |surface| try surface.core_surface.keyCallback(
input_event,
),
}; };
return switch (effect) { return switch (effect) {
.closed => true, .closed => true,
.ignored => false, .ignored => false,
.consumed => consumed: { .consumed => consumed: {
const is_down = input_event.action == .press or
input_event.action == .repeat;
if (is_down) { if (is_down) {
// If we consume the key then we want to reset the dead // If we consume the key then we want to reset the dead
// key state. // key state.
@ -1332,10 +1347,9 @@ pub const CAPI = struct {
/// Tick the event loop. This should be called whenever the "wakeup" /// Tick the event loop. This should be called whenever the "wakeup"
/// callback is invoked for the runtime. /// callback is invoked for the runtime.
export fn ghostty_app_tick(v: *App) bool { export fn ghostty_app_tick(v: *App) void {
return v.core_app.tick(v) catch |err| err: { v.core_app.tick(v) catch |err| {
log.err("error app tick err={}", .{err}); log.err("error app tick err={}", .{err});
break :err false;
}; };
} }
@ -1372,6 +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 /// Notify the app that the keyboard was changed. This causes the
/// keyboard layout to be reloaded from the OS. /// keyboard layout to be reloaded from the OS.
export fn ghostty_app_keyboard_changed(v: *App) void { export fn ghostty_app_keyboard_changed(v: *App) void {
@ -1592,16 +1628,38 @@ pub const CAPI = struct {
export fn ghostty_surface_key( export fn ghostty_surface_key(
surface: *Surface, surface: *Surface,
event: KeyEvent, event: KeyEvent,
) void { ) bool {
_ = surface.app.keyEvent( return surface.app.keyEvent(
.{ .surface = surface }, .{ .surface = surface },
event.keyEvent(), event.keyEvent(),
) catch |err| { ) catch |err| {
log.warn("error processing key event err={}", .{err}); log.warn("error processing key event err={}", .{err});
return; return false;
}; };
} }
/// Returns true if the given key event would trigger a binding
/// if it were sent to the surface right now. The "right now"
/// is important because things like trigger sequences are only
/// valid until the next key event.
export fn ghostty_surface_key_is_binding(
surface: *Surface,
event: KeyEvent,
) bool {
const core_event = surface.app.coreKeyEvent(
.{ .surface = surface },
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
return false;
} orelse {
log.warn("error processing key event", .{});
return false;
};
return surface.core_surface.keyEventIsBinding(core_event);
}
/// Send raw text to the terminal. This is treated like a paste /// Send raw text to the terminal. This is treated like a paste
/// so this isn't useful for sending escape sequences. For that, /// so this isn't useful for sending escape sequences. For that,
/// individual key input should be used. /// individual key input should be used.
@ -1895,7 +1953,7 @@ pub const CAPI = struct {
_ = CGSSetWindowBackgroundBlurRadius( _ = CGSSetWindowBackgroundBlurRadius(
CGSDefaultConnectionForThread(), CGSDefaultConnectionForThread(),
nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}),
@intCast(config.@"background-blur-radius"), @intCast(config.@"background-blur-radius".cval()),
); );
} }

View File

@ -35,6 +35,10 @@ pub const App = struct {
app: *CoreApp, app: *CoreApp,
config: Config, config: Config,
/// Flips to true to quit on the next event loop tick. This
/// never goes false and forces the event loop to exit.
quit: bool = false,
/// Mac-specific state. /// Mac-specific state.
darwin: if (Darwin.enabled) Darwin else void, darwin: if (Darwin.enabled) Darwin else void,
@ -124,8 +128,10 @@ pub const App = struct {
glfw.waitEvents(); glfw.waitEvents();
// Tick the terminal app // Tick the terminal app
const should_quit = try self.app.tick(self); try self.app.tick(self);
if (should_quit or self.app.surfaces.items.len == 0) {
// If the tick caused us to quit, then we're done.
if (self.quit or self.app.surfaces.items.len == 0) {
for (self.app.surfaces.items) |surface| { for (self.app.surfaces.items) |surface| {
surface.close(false); surface.close(false);
} }
@ -149,6 +155,8 @@ pub const App = struct {
value: apprt.Action.Value(action), value: apprt.Action.Value(action),
) !void { ) !void {
switch (action) { switch (action) {
.quit => self.quit = true,
.new_window => _ = try self.newSurface(switch (target) { .new_window => _ = try self.newSurface(switch (target) {
.app => null, .app => null,
.surface => |v| v, .surface => |v| v,
@ -510,6 +518,13 @@ pub const Surface = struct {
) orelse return glfw.mustGetErrorCode(); ) orelse return glfw.mustGetErrorCode();
errdefer win.destroy(); 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 // Get our physical DPI - debug only because we don't have a use for
// this but the logging of it may be useful // this but the logging of it may be useful
if (builtin.mode == .Debug) { 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. /// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan /// 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, /// 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 inspector = @import("inspector.zig");
const key = @import("key.zig"); const key = @import("key.zig");
const x11 = @import("x11.zig"); const x11 = @import("x11.zig");
const wayland = @import("wayland.zig");
const testing = std.testing; const testing = std.testing;
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
@ -73,6 +74,9 @@ running: bool = true,
/// Xkb state (X11 only). Will be null on Wayland. /// Xkb state (X11 only). Will be null on Wayland.
x11_xkb: ?x11.Xkb = null, 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 /// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled /// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful. /// 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 for any styles based on ghostty configuration values
css_provider: *c.GtkCssProvider, 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. /// The timer used to quit the application after the last window is closed.
quit_timer: union(enum) { quit_timer: union(enum) {
off: void, 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. // 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. // For the remainder of "why" see the 4.14 comment below.
_ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); _ = 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)) { } else if (version.atLeast(4, 14, 0)) {
// We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // 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 // 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 // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan
// and initializing a Vulkan context was causing a longer delay // and initializing a Vulkan context was causing a longer delay
// on some systems. // 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 { } else {
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It // 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 // 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); 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 // This just calls the `activate` signal but its part of the normal startup
// routine so we just call it, but only if the config allows it (this allows // routine so we just call it, but only if the config allows it (this allows
// for launching Ghostty in the "background" without immediately opening // for launching Ghostty in the "background" without immediately opening
@ -419,6 +430,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.ctx = ctx, .ctx = ctx,
.cursor_none = cursor_none, .cursor_none = cursor_none,
.x11_xkb = x11_xkb, .x11_xkb = x11_xkb,
.wayland = wl,
.single_instance = single_instance, .single_instance = single_instance,
// If we are NOT the primary instance, then we never want to run. // If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and // This means that another instance of the GTK app is running and
@ -441,6 +453,11 @@ pub fn terminate(self: *App) void {
if (self.context_menu) |context_menu| c.g_object_unref(context_menu); if (self.context_menu) |context_menu| c.g_object_unref(context_menu);
if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); 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(); self.config.deinit();
} }
@ -452,6 +469,7 @@ pub fn performAction(
value: apprt.Action.Value(action), value: apprt.Action.Value(action),
) !void { ) !void {
switch (action) { switch (action) {
.quit => self.quit(),
.new_window => _ = try self.newWindow(switch (target) { .new_window => _ = try self.newWindow(switch (target) {
.app => null, .app => null,
.surface => |v| v, .surface => |v| v,
@ -786,6 +804,7 @@ fn setInitialSize(
), ),
} }
} }
fn showDesktopNotification( fn showDesktopNotification(
self: *App, self: *App,
target: apprt.Target, target: apprt.Target,
@ -828,9 +847,11 @@ fn configChange(
new_config: *const Config, new_config: *const Config,
) void { ) void {
switch (target) { switch (target) {
// We don't do anything for surface config change events. There .surface => |surface| {
// is nothing to sync with regards to a surface today. if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| {
.surface => {}, log.warn("error syncing appearance changes to window err={}", .{err});
};
},
.app => { .app => {
// We clone (to take ownership) and update our configuration. // We clone (to take ownership) and update our configuration.
@ -892,7 +913,7 @@ fn syncConfigChanges(self: *App) !void {
try self.updateConfigErrors(); try self.updateConfigErrors();
try self.syncActionAccelerators(); 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. // with the old CSS but we don't want to fail the entire sync operation.
self.loadRuntimeCss() catch |err| switch (err) { self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn( 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 /// This should be called whenever the configuration changes to update
@ -983,6 +1007,27 @@ fn loadRuntimeCss(
unfocused_fill.b, 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)) { if (version.atLeast(4, 16, 0)) {
switch (window_theme) { switch (window_theme) {
.ghostty => try writer.print( .ghostty => try writer.print(
@ -1033,11 +1078,66 @@ fn loadRuntimeCss(
} }
// Clears any previously loaded CSS from this provider // Clears any previously loaded CSS from this provider
c.gtk_css_provider_load_from_data( loadCssProviderFromData(self.css_provider, buf.items);
self.css_provider, }
buf.items.ptr,
@intCast(buf.items.len), 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. /// 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); _ = c.g_main_context_iteration(self.ctx, 1);
// Tick the terminal app and see if we should quit. // Tick the terminal app and see if we should quit.
const should_quit = try self.core_app.tick(self); try self.core_app.tick(self);
// Check if we must quit based on the current state. // Check if we must quit based on the current state.
const must_quit = q: { const must_quit = q: {
// If we've been told by GTK that we should quit, do so regardless
// of any other setting.
if (should_quit) break :q true;
// If we are configured to always stay running, don't quit. // If we are configured to always stay running, don't quit.
if (!self.config.@"quit-after-last-window-closed") break :q false; if (!self.config.@"quit-after-last-window-closed") break :q false;
@ -1216,6 +1312,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
} }
fn quit(self: *App) void { fn quit(self: *App) void {
// If we're already not running, do nothing.
if (!self.running) return;
// If we have no toplevel windows, then we're done. // If we have no toplevel windows, then we're done.
const list = c.gtk_window_list_toplevels(); const list = c.gtk_window_list_toplevels();
if (list == null) { if (list == null) {
@ -1556,7 +1655,9 @@ fn gtkActionQuit(
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
const self: *App = @ptrCast(@alignCast(ud orelse return)); const self: *App = @ptrCast(@alignCast(ud orelse return));
self.core_app.setQuit(); self.core_app.performAction(self, .quit) catch |err| {
log.err("error quitting err={}", .{err});
};
} }
/// Action sent by the window manager asking us to present a specific surface to /// Action sent by the window manager asking us to present a specific surface to
@ -1575,7 +1676,7 @@ fn gtkActionPresentSurface(
// Convert that u64 to pointer to a core surface. A value of zero // Convert that u64 to pointer to a core surface. A value of zero
// means that there was no target surface for the notification so // 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); const ptr_int: u64 = c.g_variant_get_uint64(parameter);
if (ptr_int == 0) return; if (ptr_int == 0) return;
const surface: *CoreSurface = @ptrFromInt(ptr_int); 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_title(gtk_window, titleText(request));
c.gtk_window_set_default_size(gtk_window, 550, 275); c.gtk_window_set_default_size(gtk_window, 550, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window");
_ = c.g_signal_connect_data( _ = c.g_signal_connect_data(
window, window,
"destroy", "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_default_size(gtk_window, 600, 275);
c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_resizable(gtk_window, 0);
c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window");
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Set some state // Set some state

View File

@ -111,16 +111,6 @@ pub fn init(
// Keep a long-lived reference, which we unref in destroy. // Keep a long-lived reference, which we unref in destroy.
_ = c.g_object_ref(paned); _ = 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. // Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because // The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location // we're inheriting its parent. The sibling points to its location
@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 {
return weight; 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 // maxPosition returns the maximum position of the GtkPaned, which is the
// "max-position" attribute. // "max-position" attribute.
fn maxPosition(self: *Split) f64 { 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 behavior matches the behavior of macOS at the time of writing
// this. There is an open issue (#524) to make this depend on the // this. There is an open issue (#524) to make this depend on the
// actual physical location of the current split. // actual physical location of the current split.
result.put(.top, prev.surface); result.put(.up, prev.surface);
result.put(.left, 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| { if (self.directionNext(from)) |next| {
result.put(.next, next.surface); result.put(.next, next.surface);
if (!next.wrapped) { if (!next.wrapped) {
result.put(.bottom, next.surface); result.put(.down, next.surface);
result.put(.right, 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. // can support fractional scaling.
const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); 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 // Also scale using font-specific DPI, which is often exposed to the user
const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: { // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html).
// Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied const xft_dpi_scale = xft_scale: {
// by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html // 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(); const settings = c.gtk_settings_get_default();
var value: c.GValue = std.mem.zeroes(c.GValue); 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); c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value);
const gtk_xft_dpi = c.g_value_get_int(&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, // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by
// then divide by the default value of Xft.dpi (96) to derive a scale. // 1024, then divide by the default value (96) to derive a scale. Note
// Note that gtk-xft-dpi can be fractional, so we use floating point // gtk-xft-dpi can be fractional, so we use floating point math here.
// math here.
const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024;
break :xft_scale xft_dpi / 96; break :xft_scale xft_dpi / 96;
}; };
@ -1080,6 +1080,13 @@ pub fn setClipboardString(
if (!confirm) { if (!confirm) {
const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type); const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type);
c.gdk_clipboard_set_text(clipboard, val.ptr); c.gdk_clipboard_set_text(clipboard, val.ptr);
// We only toast if we are copying to the standard clipboard.
if (clipboard_type == .standard and
self.app.config.@"adw-toast".@"clipboard-copy")
{
if (self.container.window()) |window|
window.sendToast("Copied to clipboard");
}
return; return;
} }
@ -1426,15 +1433,23 @@ fn gtkMouseMotion(
.y = @floatCast(scaled.y), .y = @floatCast(scaled.y),
}; };
// Our pos changed, update // When the GLArea is resized under the mouse, GTK issues a mouse motion
self.cursor_pos = pos; // 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. // If we don't have focus, and we want it, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); 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(); self.grabFocus();
} }
// Our pos changed, update
self.cursor_pos = pos;
// Get our modifiers // Get our modifiers
const gtk_mods = c.gdk_event_get_modifier_state(event); const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = gtk_key.translateMods(gtk_mods); 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. // Set the userdata of the box to point to this tab.
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); 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 // Attach all events
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = 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 Notebook = @import("notebook.zig").Notebook;
const HeaderBar = @import("headerbar.zig").HeaderBar; const HeaderBar = @import("headerbar.zig").HeaderBar;
const version = @import("version.zig"); const version = @import("version.zig");
const wayland = @import("wayland.zig");
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
@ -55,6 +56,8 @@ toast_overlay: ?*c.GtkWidget,
/// See adwTabOverviewOpen for why we have this. /// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c.guint = null, adw_tab_overview_focus_timer: ?c.guint = null,
wayland: ?wayland.SurfaceState,
pub fn create(alloc: Allocator, app: *App) !*Window { pub fn create(alloc: Allocator, app: *App) !*Window {
// Allocate a fixed pointer for our window. We try to minimize // Allocate a fixed pointer for our window. We try to minimize
// allocations but windows and other GUI requirements are so minimal // allocations but windows and other GUI requirements are so minimal
@ -79,6 +82,7 @@ pub fn init(self: *Window, app: *App) !void {
.notebook = undefined, .notebook = undefined,
.context_menu = undefined, .context_menu = undefined,
.toast_overlay = undefined, .toast_overlay = undefined,
.wayland = null,
}; };
// Create the window // Create the window
@ -99,6 +103,8 @@ pub fn init(self: *Window, app: *App) !void {
self.window = gtk_window; self.window = gtk_window;
c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_title(gtk_window, "Ghostty");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window");
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window");
// GTK4 grabs F10 input by default to focus the menubar icon. We want // GTK4 grabs F10 input by default to focus the menubar icon. We want
// to disable this so that terminal programs can capture F10 (such as htop) // 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"); c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty");
} }
// Remove the window's background if any of the widgets need to be transparent
if (app.config.@"background-opacity" < 1) {
c.gtk_widget_remove_css_class(@ptrCast(window), "background");
}
// Create our box which will hold our widgets in the main content area. // Create our box which will hold our widgets in the main content area.
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
// Setup our notebook // Setup our notebook
self.notebook = Notebook.create(self); self.notebook.init();
// If we are using Adwaita, then we can support the tab overview. // 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: { 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(); 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.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data( _ = c.g_signal_connect_data(
tab_overview, tab_overview,
@ -189,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void {
.hidden => btn: { .hidden => btn: {
const btn = c.adw_tab_button_new(); 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"); c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open");
break :btn btn; 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 we have a tab overview then we can set it on our notebook.
if (self.tab_overview) |tab_overview| { if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable; if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable;
assert(self.notebook == .adw_tab_view); assert(self.notebook == .adw);
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);
} }
self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu)));
@ -288,6 +289,7 @@ pub fn init(self: *Window, app: *App) !void {
// All of our events // All of our events
_ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(&gtkRefocusTerm), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(&gtkRefocusTerm), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "realize", c.G_CALLBACK(&gtkRealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(&gtkCloseRequest), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(&gtkCloseRequest), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT);
@ -305,7 +307,7 @@ pub fn init(self: *Window, app: *App) !void {
if (self.app.config.@"gtk-tabs-location" != .hidden) { if (self.app.config.@"gtk-tabs-location" != .hidden) {
const tab_bar = c.adw_tab_bar_new(); const tab_bar = c.adw_tab_bar_new();
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); 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: { } else tab_bar: {
switch (self.notebook) { 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; 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 // In earlier adwaita versions, we need to add the tabbar manually since we do not use
// an AdwToolbarView. // an AdwToolbarView.
const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?;
@ -360,12 +361,12 @@ pub fn init(self: *Window, app: *App) !void {
), ),
.hidden => unreachable, .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); 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 // The box is our main child
@ -386,6 +387,28 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_show(window); 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 /// Sets up the GTK actions for the window scope. Actions are how GTK handles
/// menus and such. The menu is defined in App.zig but the action is defined /// menus and such. The menu is defined in App.zig but the action is defined
/// here. The string name binds them. /// here. The string name binds them.
@ -423,6 +446,8 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void { pub fn deinit(self: *Window) void {
c.gtk_widget_unparent(@ptrCast(self.context_menu)); c.gtk_widget_unparent(@ptrCast(self.context_menu));
if (self.wayland) |*wl| wl.deinit();
if (self.adw_tab_overview_focus_timer) |timer| { if (self.adw_tab_overview_focus_timer) |timer| {
_ = c.g_source_remove(timer); _ = c.g_source_remove(timer);
} }
@ -542,7 +567,7 @@ pub fn onConfigReloaded(self: *Window) void {
self.sendToast("Reloaded the configuration"); self.sendToast("Reloaded the configuration");
} }
fn sendToast(self: *Window, title: [:0]const u8) void { pub fn sendToast(self: *Window, title: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) return; if (comptime !adwaita.versionAtLeast(0, 0, 0)) return;
const toast_overlay = self.toast_overlay orelse return; const toast_overlay = self.toast_overlay orelse return;
const toast = c.adw_toast_new(title); const toast = c.adw_toast_new(title);
@ -550,6 +575,20 @@ fn sendToast(self: *Window, title: [:0]const u8) void {
c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast); c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast);
} }
fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
const self = userdataSelf(ud.?);
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 // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
// sends an undefined value. // sends an undefined value.
fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
@ -570,7 +609,7 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT
const alloc = self.app.core_app.alloc; const alloc = self.app.core_app.alloc;
const surface = self.actionSurface(); const surface = self.actionSurface();
const tab = Tab.create(alloc, self, surface) catch return null; 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( fn adwTabOverviewOpen(
@ -894,8 +933,6 @@ fn gtkActionCopy(
log.warn("error performing binding action error={}", .{err}); log.warn("error performing binding action error={}", .{err});
return; return;
}; };
self.sendToast("Copied to clipboard");
} }
fn gtkActionPaste( fn gtkActionPaste(

View File

@ -14,6 +14,9 @@ pub const c = @cImport({
// Xkb for X11 state handling // Xkb for X11 state handling
@cInclude("X11/XKBlib.h"); @cInclude("X11/XKBlib.h");
} }
if (build_options.wayland) {
@cInclude("gdk/wayland/gdkwayland.h");
}
// generated header files // generated header files
@cInclude("ghostty_resources.h"); @cInclude("ghostty_resources.h");

View File

@ -143,6 +143,8 @@ const Window = struct {
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_default_size(gtk_window, 1000, 600);
c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id);
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window");
c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window");
// Initialize our imgui widget // Initialize our imgui widget
try self.imgui_widget.init(); 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 // 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 // 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)); 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 Window = @import("Window.zig");
const Tab = @import("Tab.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 adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk); const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; 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 /// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window. /// all the terminal tabs in a window.
pub const Notebook = union(enum) { pub const Notebook = union(enum) {
adw_tab_view: *AdwTabView, adw: NotebookAdw,
gtk_notebook: *c.GtkNotebook, gtk: NotebookGtk,
pub fn create(window: *Window) Notebook { pub fn init(self: *Notebook) void {
const window: *Window = @fieldParentPtr("notebook", self);
const app = window.app; const app = window.app;
if (adwaita.enabled(&app.config)) return initAdw(window); if (adwaita.enabled(&app.config)) return NotebookAdw.init(self);
return initGtk(window);
return NotebookGtk.init(self);
} }
fn initGtk(window: *Window) Notebook { pub fn asWidget(self: *Notebook) *c.GtkWidget {
const app = window.app; return switch (self.*) {
.adw => |*adw| adw.asWidget(),
// Create a notebook to hold our tabs. .gtk => |*gtk| gtk.asWidget(),
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 nPages(self: Notebook) c_int { pub fn nPages(self: *Notebook) c_int {
return switch (self) { return switch (self.*) {
.gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), .adw => |*adw| adw.nPages(),
.adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) .gtk => |*gtk| gtk.nPages(),
c.adw_tab_view_get_n_pages(tab_view)
else
unreachable,
}; };
} }
/// Returns the index of the currently selected page. /// Returns the index of the currently selected page.
/// Returns null if the notebook has no pages. /// Returns null if the notebook has no pages.
fn currentPage(self: Notebook) ?c_int { fn currentPage(self: *Notebook) ?c_int {
switch (self) { return switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.currentPage(),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.currentPage(),
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;
},
}
} }
/// Returns the currently selected tab or null if there are none. /// Returns the currently selected tab or null if there are none.
pub fn currentTab(self: Notebook) ?*Tab { pub fn currentTab(self: *Notebook) ?*Tab {
const child = switch (self) { return switch (self.*) {
.adw_tab_view => |tab_view| child: { .adw => |*adw| adw.currentTab(),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.currentTab(),
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 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; const page_idx = self.getTabPosition(tab) orelse return;
// The next index is the previous or we wrap around. // The next index is the previous or we wrap around.
@ -173,7 +88,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx); 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 page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1; const max = self.nPages() -| 1;
@ -183,7 +98,7 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx); 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 page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1; const max = self.nPages() -| 1;
@ -199,42 +114,28 @@ pub const Notebook = union(enum) {
self.reorderPage(tab, new_position); self.reorderPage(tab, new_position);
} }
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void {
switch (self) { switch (self.*) {
.gtk_notebook => |notebook| { .adw => |*adw| adw.reorderPage(tab, position),
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); .gtk => |*gtk| gtk.reorderPage(tab, 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 setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.setTabLabel(tab, title),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.setTabLabel(tab, title),
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 setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void {
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.setTabTooltip(tab, tooltip),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip),
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),
} }
} }
fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int {
const numPages = self.nPages(); const numPages = self.nPages();
return switch (tab.window.app.config.@"window-new-tab-position") { return switch (tab.window.app.config.@"window-new-tab-position") {
.current => if (self.currentPage()) |page| page + 1 else numPages, .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. /// Adds a new tab with the given title to the notebook.
pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
const box_widget: *c.GtkWidget = @ptrCast(tab.box); const position = self.newTabInsertPosition(tab);
switch (self) { switch (self.*) {
.adw_tab_view => |tab_view| { .adw => |*adw| adw.addTab(tab, position, title),
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; .gtk => |*gtk| gtk.addTab(tab, position, title),
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 closeTab(self: Notebook, tab: *Tab) void { pub fn closeTab(self: *Notebook, tab: *Tab) void {
const window = tab.window; switch (self.*) {
switch (self) { .adw => |*adw| adw.closeTab(tab),
.adw_tab_view => |tab_view| { .gtk => |*gtk| gtk.closeTab(tab),
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);
} }
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( pub fn createWindow(currentWindow: *Window) !*Window {
_: *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 {
const alloc = currentWindow.app.core_app.alloc; const alloc = currentWindow.app.core_app.alloc;
const app = currentWindow.app; 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; background-color: transparent;
} }
separator { .terminal-window .notebook separator {
background-color: rgba(36, 36, 36, 1); background-color: rgba(36, 36, 36, 1);
background-clip: content-box; background-clip: content-box;
} }

View File

@ -41,7 +41,7 @@ window.without-window-decoration-and-with-titlebar {
background-color: transparent; background-color: transparent;
} }
separator { .terminal-window .notebook separator {
background-color: rgba(250, 250, 250, 1); background-color: rgba(250, 250, 250, 1);
background-clip: content-box; 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, flatpak: bool = false,
adwaita: bool = false, adwaita: bool = false,
x11: bool = false, x11: bool = false,
wayland: bool = false,
sentry: bool = true,
app_runtime: apprt.Runtime = .none, app_runtime: apprt.Runtime = .none,
renderer: rendererpkg.Impl = .opengl, renderer: rendererpkg.Impl = .opengl,
font_backend: font.Backend = .freetype, font_backend: font.Backend = .freetype,
@ -43,6 +45,8 @@ pub const BuildConfig = struct {
step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "adwaita", self.adwaita); step.addOption(bool, "adwaita", self.adwaita);
step.addOption(bool, "x11", self.x11); 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(apprt.Runtime, "app_runtime", self.app_runtime);
step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(font.Backend, "font_backend", self.font_backend);
step.addOption(rendererpkg.Impl, "renderer", self.renderer); step.addOption(rendererpkg.Impl, "renderer", self.renderer);

View File

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

View File

@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 {
try stdout.writeAll( try stdout.writeAll(
\\ \\
\\Specify `+<action> --help` to see the help for a specific action, \\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 /// The `list-actions` command is used to list all the available keybind
/// for Ghostty. /// 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. /// The `--docs` argument will print out the documentation for each action.
pub fn run(alloc: Allocator) !u8 { pub fn run(alloc: Allocator) !u8 {

View File

@ -11,6 +11,12 @@ const global_state = &@import("../global.zig").state;
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zf = @import("zf"); 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 { pub const Options = struct {
/// If true, print the full path to the theme. /// If true, print the full path to the theme.
path: bool = false, path: bool = false,
@ -323,9 +329,15 @@ const Preview = struct {
} }
self.current, self.window = current: { self.current, self.window = current: {
if (selected.len == 0) break :current .{ 0, 0 };
for (self.filtered.items, 0..) |index, i| { for (self.filtered.items, 0..) |index, i| {
if (std.mem.eql(u8, self.themes[index].theme, selected)) if (std.mem.eql(u8, self.themes[index].theme, selected)) {
break :current .{ i, i -| relative }; // 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 }; break :current .{ 0, 0 };
}; };

View File

@ -3,6 +3,7 @@ const build_options = @import("build_options");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../build_config.zig"); const build_config = @import("../build_config.zig");
const internal_os = @import("../os/main.zig");
const xev = @import("xev"); const xev = @import("xev");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; 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(" - renderer : {}\n", .{renderer.Renderer});
try stdout.print(" - libxev : {}\n", .{xev.backend}); try stdout.print(" - libxev : {}\n", .{xev.backend});
if (comptime build_config.app_runtime == .gtk) { 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(" - GTK version:\n", .{});
try stdout.print(" build : {d}.{d}.{d}\n", .{ try stdout.print(" build : {d}.{d}.{d}\n", .{
gtk.GTK_MAJOR_VERSION, gtk.GTK_MAJOR_VERSION,
@ -66,6 +68,14 @@ pub fn run(alloc: Allocator) !u8 {
} else { } else {
try stdout.print(" - libX11 : disabled\n", .{}); 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; return 0;
} }

View File

@ -147,23 +147,28 @@ const c = @cImport({
/// By default, synthetic styles are enabled. /// By default, synthetic styles are enabled.
@"font-synthetic-style": FontSyntheticStyle = .{}, @"font-synthetic-style": FontSyntheticStyle = .{},
/// Apply a font feature. This can be repeated multiple times to enable multiple /// Apply a font feature. To enable multiple font features you can repeat
/// font features. You can NOT set multiple font features with a single value /// this multiple times or use a comma-separated list of feature settings.
/// (yet). ///
/// 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 /// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces. /// 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 /// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features /// 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 /// your font has and what they do, use a font inspection tool such as
/// [fontdrop.info](https://fontdrop.info). /// [fontdrop.info](https://fontdrop.info).
/// ///
/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as /// To generally disable most ligatures, use `-calt, -liga, -dlig`.
/// separate repetitive entries in your config).
@"font-feature": RepeatableString = .{}, @"font-feature": RepeatableString = .{},
/// Font size in points. This value can be a non-integer and the nearest integer /// 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 /// 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 /// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window. /// 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) { @"font-size": f32 = switch (builtin.os.tag) {
// On macOS we default a little bigger since this tends to look better. This // On macOS we default a little bigger since this tends to look better. This
// is purely subjective but this is easy to modify. // is purely subjective but this is easy to modify.
@ -225,10 +234,20 @@ const c = @cImport({
/// i.e. new windows, tabs, etc. /// i.e. new windows, tabs, etc.
@"font-codepoint-map": RepeatableCodepointMap = .{}, @"font-codepoint-map": RepeatableCodepointMap = .{},
/// Draw fonts with a thicker stroke, if supported. This is only supported /// Draw fonts with a thicker stroke, if supported.
/// currently on macOS. /// This is currently only supported on macOS.
@"font-thicken": bool = false, @"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 /// 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%, /// 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 /// 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 /// 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 /// 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` /// explicitly disable flags you don't want. You can also use `true` or `false`
/// to turn all flags on or off. /// to turn all flags on or off.
/// ///
@ -398,14 +417,17 @@ const c = @cImport({
theme: ?Theme = null, theme: ?Theme = null,
/// Background color for the window. /// 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 }, background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
/// Foreground color for the window. /// 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 }, foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The foreground and background color for selection. If this is not set, then /// The foreground and background color for selection. If this is not set, then
/// the selection color is just the inverted window background and foreground /// the selection color is just the inverted window background and foreground
/// (note: not to be confused with the cell bg/fg). /// (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-foreground": ?Color = null,
@"selection-background": ?Color = null, @"selection-background": ?Color = null,
@ -431,15 +453,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
@"minimum-contrast": f64 = 1, @"minimum-contrast": f64 = 1,
/// Color palette for the 256 color form that many terminal applications use. /// 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 syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for
/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB
/// color code such as `#AABBCC`. /// color code such as `#AABBCC` or `AABBCC`, or a named X11 color.
/// ///
/// For definitions on all the codes [see this cheat /// The palette index can be in decimal, binary, octal, or hexadecimal.
/// sheet](https://www.ditig.com/256-colors-cheat-sheet). /// 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 = .{}, palette: Palette = .{},
/// The color of the cursor. If this is not set, a default will be chosen. /// 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, @"cursor-color": ?Color = null,
/// Swap the foreground and background colors of the cell under the cursor. This /// 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 /// The color of the text under the cursor. If this is not set, a default will
/// be chosen. /// be chosen.
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"cursor-text": ?Color = null, @"cursor-text": ?Color = null,
/// Enables the ability to move the cursor at prompts by using `alt+click` on /// 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 /// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value. /// 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. /// double the default amount. A value of "0.5" scrolls half the default amount.
/// Et cetera. /// Et cetera.
@"mouse-scroll-multiplier": f64 = 1.0, @"mouse-scroll-multiplier": f64 = 1.0,
@ -560,15 +588,42 @@ palette: Palette = .{},
/// On macOS, background opacity is disabled when the terminal enters native /// On macOS, background opacity is disabled when the terminal enters native
/// fullscreen. This is because the background becomes gray and it can cause /// fullscreen. This is because the background becomes gray and it can cause
/// widgets to show through which isn't generally desirable. /// widgets to show through which isn't generally desirable.
///
/// On macOS, changing this configuration requires restarting Ghostty completely.
@"background-opacity": f64 = 1.0, @"background-opacity": f64 = 1.0,
/// A positive value enables blurring of the background when background-opacity /// Whether to blur the background when `background-opacity` is less than 1.
/// 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.
/// ///
/// This is only supported on macOS. /// Valid values are:
@"background-blur-radius": u8 = 0, ///
/// * 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. /// The opacity level (opposite of transparency) of an unfocused split.
/// Unfocused splits by default are slightly faded out to make it easier to see /// 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. /// that rectangle and can be used to carefully control the dimming effect.
/// ///
/// This will default to the background color. /// This will default to the background color.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"unfocused-split-fill": ?Color = null, @"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 /// 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 /// 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: /// 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 /// 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 /// will update for all windows. If it is unset, the next title change escape
/// sequence will be honored but previous changes will not retroactively /// 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. /// to get the new title.
title: ?[:0]const u8 = null, title: ?[:0]const u8 = null,
@ -907,6 +968,15 @@ class: ?[:0]const u8 = null,
/// Since they are not associated with a specific terminal surface, /// Since they are not associated with a specific terminal surface,
/// they're never encoded. /// 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, /// Keybind triggers are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// `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 /// 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. /// 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, @"window-title-font-family": ?[:0]const u8 = null,
/// The theme to use for the windows. Valid values: /// The theme to use for the windows. Valid values:
@ -1104,6 +1177,32 @@ keybind: Keybinds = .{},
@"window-height": u32 = 0, @"window-height": u32 = 0,
@"window-width": 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 /// Whether to enable saving and restoring window state. Window state includes
/// their position, size, tabs, splits, etc. Some window state requires shell /// their position, size, tabs, splits, etc. Some window state requires shell
/// integration, such as preserving working directories. See `shell-integration` /// 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 /// Background color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app /// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime. /// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-background": ?Color = null, @"window-titlebar-background": ?Color = null,
/// Foreground color for the window titlebar. This only takes effect if /// Foreground color for the window titlebar. This only takes effect if
/// window-theme is set to ghostty. Currently only supported in the GTK app /// window-theme is set to ghostty. Currently only supported in the GTK app
/// runtime. /// runtime.
///
/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
@"window-titlebar-foreground": ?Color = null, @"window-titlebar-foreground": ?Color = null,
/// This controls when resize overlays are shown. Resize overlays are a /// 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 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 /// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`. /// `custom-style`.
/// ///
/// This only has an effect 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, @"macos-icon-ghost-color": ?Color = null,
/// The color of the screen in the macOS app icon. /// The color of the screen in the macOS app icon.
/// ///
/// The screen is a gradient so you can specify multiple colors that /// The screen is a gradient so you can specify multiple colors that
/// make up the gradient. Colors should be separated by commas. The /// make up the gradient. Comma-separated colors may be specified as
/// format of the color is the same as the `background` configuration; /// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors.
/// see that for more information.
/// ///
/// Note: This configuration is required when `macos-icon` is set to /// Note: This configuration is required when `macos-icon` is set to
/// `custom-style`. /// `custom-style`.
@ -1905,6 +2006,29 @@ keybind: Keybinds = .{},
/// Changing this value at runtime will only affect new windows. /// Changing this value at runtime will only affect new windows.
@"adw-toolbar-style": AdwToolbarStyle = .raised, @"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 /// 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. /// 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, /// If you set this to `false` then tabs will only take up space they need,
@ -1925,6 +2049,15 @@ keybind: Keybinds = .{},
/// Adwaita support. /// Adwaita support.
@"gtk-adwaita": bool = true, @"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 /// If `true` (default), applications running in the terminal can show desktop
/// notifications using certain escape sequences such as OSC 9 or OSC 777. /// notifications using certain escape sequences such as OSC 9 or OSC 777.
@"desktop-notifications": bool = true, @"desktop-notifications": bool = true,
@ -1963,10 +2096,11 @@ term: []const u8 = "xterm-ghostty",
/// * `download` - Check for updates, automatically download the update, /// * `download` - Check for updates, automatically download the update,
/// notify the user, but do not automatically install 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. /// 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. /// 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 // On macOS we default to super but Linux ctrl+shift since
// ctrl+c is to kill the process. // ctrl+c is to kill the process.
const mods: inputpkg.Mods = if (builtin.target.isDarwin()) const mods: inputpkg.Mods = if (builtin.target.isDarwin())
@ -2124,45 +2277,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
); );
// Expand Selection // Expand Selection
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } },
.{ .adjust_selection = .left }, .{ .adjust_selection = .left },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } },
.{ .adjust_selection = .right }, .{ .adjust_selection = .right },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .up }, .{ .adjust_selection = .up },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .down }, .{ .adjust_selection = .down },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_up }, .{ .adjust_selection = .page_up },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } },
.{ .adjust_selection = .page_down }, .{ .adjust_selection = .page_down },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } },
.{ .adjust_selection = .home }, .{ .adjust_selection = .home },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } },
.{ .adjust_selection = .end }, .{ .adjust_selection = .end },
.{ .performable = true },
); );
// Tabs common to all platforms // Tabs common to all platforms
@ -2247,12 +2408,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .top }, .{ .goto_split = .up },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } },
.{ .goto_split = .bottom }, .{ .goto_split = .down },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2412,10 +2573,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .key = .{ .translated = .q }, .mods = .{ .super = true } },
.{ .quit = {} }, .{ .quit = {} },
); );
try result.keybind.set.put( try result.keybind.set.putFlags(
alloc, alloc,
.{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } },
.{ .clear_screen = {} }, .{ .clear_screen = {} },
.{ .performable = true },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2516,12 +2678,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .top }, .{ .goto_split = .up },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
.{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } },
.{ .goto_split = .bottom }, .{ .goto_split = .down },
); );
try result.keybind.set.put( try result.keybind.set.put(
alloc, alloc,
@ -2695,6 +2857,9 @@ pub fn loadOptionalFile(
fn writeConfigTemplate(path: []const u8) !void { fn writeConfigTemplate(path: []const u8) !void {
log.info("creating template config file: path={s}", .{path}); 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, .{}); const file = try std.fs.createFileAbsolute(path, .{});
defer file.close(); defer file.close();
try std.fmt.format( try std.fmt.format(
@ -2798,7 +2963,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
self.@"config-default-files" = true; self.@"config-default-files" = true;
// Keep track of the replay steps up to this point so we // 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; const replay_len_start = self._replay_steps.items.len;
// Keep track of font families because if they are set from the CLI // 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 const eqlIdx = std.mem.indexOf(u8, value, "=") orelse
return error.InvalidValue; 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 ..]); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; 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); 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" { test "parseCLI overflow" {
const testing = std.testing; const testing = std.testing;
@ -4340,6 +4527,45 @@ pub const RepeatablePath = struct {
// If it isn't absolute, we need to make it absolute relative // If it isn't absolute, we need to make it absolute relative
// to the base. // to the base.
var buf: [std.fs.max_path_bytes]u8 = undefined; 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: { const abs = dir.realpath(path, &buf) catch |err| abs: {
if (err == error.FileNotFound) { if (err == error.FileNotFound) {
// The file doesn't exist. Try to resolve the relative path // The file doesn't exist. Try to resolve the relative path
@ -5348,6 +5574,11 @@ pub const AdwToolbarStyle = enum {
@"raised-border", @"raised-border",
}; };
/// See adw-toast
pub const AdwToast = packed struct {
@"clipboard-copy": bool = true,
};
/// See mouse-shift-capture /// See mouse-shift-capture
pub const MouseShiftCapture = enum { pub const MouseShiftCapture = enum {
false, false,
@ -5441,6 +5672,70 @@ pub const AutoUpdate = enum {
download, 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 /// See theme
pub const Theme = struct { pub const Theme = struct {
light: []const u8, light: []const u8,

View File

@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
ptr.* = @intCast(value); ptr.* = @intCast(value);
}, },
i16 => {
const ptr: *c_short = @ptrCast(@alignCast(ptr_raw));
ptr.* = @intCast(value);
},
f32, f64 => |Float| { f32, f64 => |Float| {
const ptr: *Float = @ptrCast(@alignCast(ptr_raw)); const ptr: *Float = @ptrCast(@alignCast(ptr_raw));
ptr.* = @floatCast(value); ptr.* = @floatCast(value);
@ -79,6 +84,17 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool {
ptr.* = @intCast(@as(Backing, @bitCast(value))); 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, else => return false,
}, },
} }
@ -167,3 +183,30 @@ test "c_get: optional" {
try testing.expectEqual(0, cval.b); 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 Allocator = std.mem.Allocator;
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("../build_config.zig"); 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 internal_os = @import("../os/main.zig");
const crash = @import("main.zig"); const crash = @import("main.zig");
const state = &@import("../global.zig").state; 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 /// 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. /// (or they own Sentry instance) if they want to.
pub fn init(gpa: Allocator) !void { pub fn init(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
// Not supported on Windows currently, doesn't build. // Not supported on Windows currently, doesn't build.
if (comptime builtin.os.tag == .windows) return; if (comptime builtin.os.tag == .windows) return;
@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void {
} }
fn initThread(gpa: Allocator) !void { fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
var arena = std.heap.ArenaAllocator.init(gpa); var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit(); defer arena.deinit();
const alloc = arena.allocator(); const alloc = arena.allocator();
@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void {
sentry.c.sentry_options_set_before_send(opts, beforeSend, null); sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
// Determine the Sentry cache directory. // 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( sentry.c.sentry_options_set_database_path_n(
opts, opts,
cache_dir.ptr, cache_dir.ptr,
@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void {
/// Process-wide deinitialization of our Sentry client. This ensures all /// Process-wide deinitialization of our Sentry client. This ensures all
/// our data is flushed. /// our data is flushed.
pub fn deinit() void { pub fn deinit() void {
if (comptime !build_options.sentry) return;
if (comptime builtin.os.tag == .windows) return; if (comptime builtin.os.tag == .windows) return;
// If we're still initializing then wait for init to finish. This // If we're still initializing then wait for init to finish. This

View File

@ -100,6 +100,15 @@ pub const RenderOptions = struct {
/// ///
/// This only works with CoreText currently. /// This only works with CoreText currently.
thicken: bool = false, 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 { test {

View File

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

View File

@ -1,6 +1,7 @@
const builtin = @import("builtin"); const builtin = @import("builtin");
const options = @import("main.zig").options; const options = @import("main.zig").options;
const run = @import("shaper/run.zig"); const run = @import("shaper/run.zig");
const feature = @import("shaper/feature.zig");
pub const noop = @import("shaper/noop.zig"); pub const noop = @import("shaper/noop.zig");
pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig");
pub const coretext = @import("shaper/coretext.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 Cache = @import("shaper/Cache.zig");
pub const TextRun = run.TextRun; pub const TextRun = run.TextRun;
pub const RunIterator = run.RunIterator; 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. /// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) { pub const Shaper = switch (options.backend) {
@ -49,10 +53,7 @@ pub const Cell = struct {
/// Options for shapers. /// Options for shapers.
pub const Options = struct { pub const Options = struct {
/// Font features to use when shaping. These can be in the following /// Font features to use when shaping.
/// 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.
/// ///
/// Note: eventually, this will move to font.Face probably as we may /// Note: eventually, this will move to font.Face probably as we may
/// want to support per-face feature configuration. For now, we only /// 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 font = @import("../main.zig");
const os = @import("../../os/main.zig"); const os = @import("../../os/main.zig");
const terminal = @import("../../terminal/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 Face = font.Face;
const Collection = font.Collection; const Collection = font.Collection;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@ -40,9 +43,10 @@ pub const Shaper = struct {
/// The string used for shaping the current run. /// The string used for shaping the current run.
run_state: RunState, run_state: RunState,
/// The font features we want to use. The hardcoded features are always /// CoreFoundation Dictionary which represents our font feature settings.
/// set first. features: *macos.foundation.Dictionary,
features: FeatureList, /// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// The shared memory used for shaping results. /// The shared memory used for shaping results.
cell_buf: CellBuf, cell_buf: CellBuf,
@ -100,51 +104,17 @@ pub const Shaper = struct {
} }
}; };
/// List of font features, parsed into the data structures used by /// Create a CoreFoundation Dictionary suitable for
/// the CoreText API. The CoreText API requires a pretty annoying wrapping /// settings the font features of a CoreText font.
/// to setup font features: fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary {
/// const list = try macos.foundation.MutableArray.create();
/// - 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();
errdefer list.release(); errdefer list.release();
return .{ .list = list };
}
pub fn deinit(self: FeatureList) void { for (feats) |feat| {
self.list.release(); const value_num: c_int = @intCast(feat.value);
}
/// 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);
// Keys can only be ASCII. // 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(); defer key.release();
var value = try macos.foundation.Number.create(.int, &value_num); var value = try macos.foundation.Number.create(.int, &value_num);
defer value.release(); defer value.release();
@ -154,50 +124,44 @@ pub const Shaper = struct {
macos.text.c.kCTFontOpenTypeFeatureTag, macos.text.c.kCTFontOpenTypeFeatureTag,
macos.text.c.kCTFontOpenTypeFeatureValue, macos.text.c.kCTFontOpenTypeFeatureValue,
}, },
&[_]?*const anyopaque{ &[_]?*const anyopaque{ key, value },
key,
value,
},
); );
errdefer dict.release(); defer dict.release();
return dict;
}
/// Returns the dictionary to use with the font API to set the list.appendValue(macos.foundation.Dictionary, dict);
/// 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();
var dict = try macos.foundation.Dictionary.create( var dict = try macos.foundation.Dictionary.create(
&[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute},
&[_]?*const anyopaque{list}, &[_]?*const anyopaque{list},
); );
errdefer dict.release(); errdefer dict.release();
return dict; 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. /// 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. /// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
var feats = try FeatureList.init(); var feature_list: FeatureList = .{};
errdefer feats.deinit(); defer feature_list.deinit(alloc);
for (hardcoded_features) |name| try feats.append(name); for (opts.features) |feature_str| {
for (opts.features) |name| try feats.append(name); 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(); var run_state = RunState.init();
errdefer run_state.deinit(alloc); errdefer run_state.deinit(alloc);
@ -242,7 +206,8 @@ pub const Shaper = struct {
.alloc = alloc, .alloc = alloc,
.cell_buf = .{}, .cell_buf = .{},
.run_state = run_state, .run_state = run_state,
.features = feats, .features = features,
.features_no_default = features_no_default,
.writing_direction = writing_direction, .writing_direction = writing_direction,
.cached_fonts = .{}, .cached_fonts = .{},
.cached_font_grid = 0, .cached_font_grid = 0,
@ -255,7 +220,8 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void { pub fn deinit(self: *Shaper) void {
self.cell_buf.deinit(self.alloc); self.cell_buf.deinit(self.alloc);
self.run_state.deinit(self.alloc); self.run_state.deinit(self.alloc);
self.features.deinit(); self.features.release();
self.features_no_default.release();
self.writing_direction.release(); self.writing_direction.release();
{ {
@ -509,8 +475,8 @@ pub const Shaper = struct {
// If we have it, return the cached attr dict. // If we have it, return the cached attr dict.
if (self.cached_fonts.items[index_int]) |cached| return cached; if (self.cached_fonts.items[index_int]) |cached| return cached;
// Features dictionary, font descriptor, font // Font descriptor, font
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2);
const run_font = font: { const run_font = font: {
// The CoreText shaper relies on CoreText and CoreText claims // 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 face = try grid.resolver.collection.getFace(index);
const original = face.font; const original = face.font;
const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features); const attrs = if (face.quirks_disable_default_font_features)
self.cf_release_pool.appendAssumeCapacity(attrs); self.features_no_default
else
self.features;
const desc = try macos.text.FontDescriptor.createWithAttributes(attrs); const desc = try macos.text.FontDescriptor.createWithAttributes(attrs);
self.cf_release_pool.appendAssumeCapacity(desc); 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 Allocator = std.mem.Allocator;
const harfbuzz = @import("harfbuzz"); const harfbuzz = @import("harfbuzz");
const font = @import("../main.zig"); 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 Face = font.Face;
const Collection = font.Collection; const Collection = font.Collection;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@ -10,7 +14,6 @@ const Library = font.Library;
const SharedGrid = font.SharedGrid; const SharedGrid = font.SharedGrid;
const Style = font.Style; const Style = font.Style;
const Presentation = font.Presentation; const Presentation = font.Presentation;
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.font_shaper); const log = std.log.scoped(.font_shaper);
@ -27,38 +30,37 @@ pub const Shaper = struct {
cell_buf: CellBuf, cell_buf: CellBuf,
/// The features to use for shaping. /// The features to use for shaping.
hb_feats: FeatureList, hb_feats: []harfbuzz.Feature,
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); 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. /// 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. /// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
// Parse all the features we want to use. We use // Parse all the features we want to use.
var hb_feats = hb_feats: { const hb_feats = hb_feats: {
var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len); var feature_list: FeatureList = .{};
errdefer list.deinit(alloc); defer feature_list.deinit(alloc);
try feature_list.features.appendSlice(alloc, &default_features);
for (hardcoded_features) |name| { for (opts.features) |feature_str| {
if (harfbuzz.Feature.fromString(name)) |feat| { try feature_list.appendFromString(alloc, feature_str);
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name});
} }
for (opts.features) |name| { var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len);
if (harfbuzz.Feature.fromString(name)) |feat| { errdefer alloc.free(list);
try list.append(alloc, feat);
} else log.warn("failed to parse font feature: {s}", .{name}); 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; break :hb_feats list;
}; };
errdefer hb_feats.deinit(alloc); errdefer alloc.free(hb_feats);
return Shaper{ return Shaper{
.alloc = alloc, .alloc = alloc,
@ -71,7 +73,7 @@ pub const Shaper = struct {
pub fn deinit(self: *Shaper) void { pub fn deinit(self: *Shaper) void {
self.hb_buf.destroy(); self.hb_buf.destroy();
self.cell_buf.deinit(self.alloc); 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 { 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 // If we are disabling default font features we just offset
// our features by the hardcoded items because always // our features by the hardcoded items because always
// add those at the beginning. // 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 // 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, alloc: std.mem.Allocator,
action: ?cli.Action, action: ?cli.Action,
logging: Logging, logging: Logging,
rlimits: ResourceLimits = .{},
/// The app resources directory, equivalent to zig-out/share when we build /// The app resources directory, equivalent to zig-out/share when we build
/// from source. This is null if we can't detect it. /// from source. This is null if we can't detect it.
@ -56,6 +57,7 @@ pub const GlobalState = struct {
.alloc = undefined, .alloc = undefined,
.action = null, .action = null,
.logging = .{ .stderr = {} }, .logging = .{ .stderr = {} },
.rlimits = .{},
.resources_dir = null, .resources_dir = null,
}; };
errdefer self.deinit(); errdefer self.deinit();
@ -123,8 +125,8 @@ pub const GlobalState = struct {
std.log.info("renderer={}", .{renderer.Renderer}); std.log.info("renderer={}", .{renderer.Renderer});
std.log.info("libxev backend={}", .{xev.backend}); std.log.info("libxev backend={}", .{xev.backend});
// First things first, we fix our file descriptors // As early as possible, initialize our resource limits.
internal_os.fixMaxFiles(); self.rlimits = ResourceLimits.init();
// Initialize our crash reporting. // Initialize our crash reporting.
crash.init(self.alloc) catch |err| { 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. /// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information. /// See the keybind config documentation for more information.
global: bool = false, 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 /// 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")) { } else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat; if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false; flags.consumed = false;
} else if (std.mem.eql(u8, prefix, "performable")) {
if (flags.performable) return Error.InvalidFormat;
flags.performable = true;
} else { } else {
// If we don't recognize the prefix then we're done. // If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so // 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; if (rhs.trigger.mods.alt) count += 1;
break :blk count; 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.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. /// The set of actions that a keybinding can take.
@ -203,7 +230,7 @@ pub const Action = union(enum) {
unbind: void, unbind: void,
/// Send a CSI sequence. The value should be the CSI sequence without the /// 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, csi: []const u8,
/// Send an `ESC` sequence. /// Send an `ESC` sequence.
@ -302,7 +329,7 @@ pub const Action = union(enum) {
goto_tab: usize, goto_tab: usize,
/// Moves a tab by a relative offset. /// 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. /// If the new position is out of bounds, it wraps around cyclically within the tab range.
move_tab: isize, move_tab: isize,
@ -311,17 +338,18 @@ pub const Action = union(enum) {
toggle_tab_overview: void, toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in /// 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, 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, goto_split: SplitFocusDirection,
/// zoom/unzoom the current split. /// zoom/unzoom the current split.
toggle_split_zoom: void, toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given /// 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, resize_split: SplitResizeParameter,
/// Equalize all splits in the current window /// Equalize all splits in the current window
@ -478,10 +506,42 @@ pub const Action = union(enum) {
previous, previous,
next, next,
top, up,
left, left,
bottom, down,
right, 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 { pub const SplitResizeDirection = enum {
@ -524,7 +584,16 @@ pub const Action = union(enum) {
comptime field: std.builtin.Type.UnionField, comptime field: std.builtin.Type.UnionField,
param: []const u8, param: []const u8,
) !field.type { ) !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), .Enum => try parseEnum(field.type, param),
.Int => try parseInt(field.type, param), .Int => try parseInt(field.type, param),
.Float => try parseFloat(field.type, param), .Float => try parseFloat(field.type, param),
@ -1647,6 +1716,16 @@ test "parse: triggers" {
.flags = .{ .consumed = false }, .flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore")); }, 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 // invalid key
try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); 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 // If we match a control sequence, we output that directly. For
// ctrlSeq we have to use all mods because we want it to only // ctrlSeq we have to use all mods because we want it to only
// match ctrl+<char>. // 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. // C0 sequences support alt-as-esc prefixing.
if (binding_mods.alt) { if (binding_mods.alt) {
if (buf.len < 2) return error.OutOfMemory; 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 /// into a C0 byte. There are many cases for this and you should read
/// the source code to understand them. /// the source code to understand them.
fn ctrlSeq( fn ctrlSeq(
logical_key: key.Key,
utf8: []const u8, utf8: []const u8,
unshifted_codepoint: u21, unshifted_codepoint: u21,
mods: key.Mods, mods: key.Mods,
) ?u8 { ) ?u8 {
const ctrl_only = comptime (key.Mods{ .ctrl = true }).int();
// If ctrl is not pressed then we never do anything. // If ctrl is not pressed then we never do anything.
if (!mods.ctrl) return null; 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: { const char, const unset_mods = unset_mods: {
var char = utf8[0];
var unset_mods = mods; var unset_mods = mods;
// Remove alt from our modifiers because it does not impact whether // Remove alt from our modifiers because it does not impact whether
@ -558,6 +561,34 @@ fn ctrlSeq(
// logic separately. // logic separately.
unset_mods.alt = false; 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 // Remove shift if we have something outside of the US letter
// range. This is so that characters such as `ctrl+shift+-` // range. This is so that characters such as `ctrl+shift+-`
// generate the correct ctrl-seq (used by emacs). // 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. // 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; if (unset_mods.int() != ctrl_only) return null;
// From Kitty's key encoding logic. I tried to discern the exact // 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); const actual = try enc.legacy(&buf);
try testing.expectEqualStrings("[337;5u", actual[1..]); try testing.expectEqualStrings("[337;5u", actual[1..]);
} }
test "ctrlseq: normal ctrl c" { 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.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: normal ctrl c, right control" { 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.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: alt should be allowed" { 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.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: no ctrl does nothing" { 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" { 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.?); try testing.expectEqual(@as(u8, 0x1F), seq.?);
} }
test "ctrlseq: caps ascii letter" { 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.?); try testing.expectEqual(@as(u8, 0x03), seq.?);
} }
test "ctrlseq: shift does not generate ctrl seq" { test "ctrlseq: shift does not generate ctrl seq" {
try testing.expect(ctrlSeq("C", 'c', .{ .shift = true }) == null); try testing.expect(ctrlSeq(.invalid, "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, .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, eight,
nine, nine,
// puncuation // punctuation
semicolon, semicolon,
space, space,
apostrophe, apostrophe,
@ -411,7 +411,7 @@ pub const Key = enum(c_int) {
/// may be from the number row or the keypad, but it always maps /// may be from the number row or the keypad, but it always maps
/// to '.zero'. /// 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. /// are independent of the physical key.
pub fn fromASCII(ch: u8) ?Key { pub fn fromASCII(ch: u8) ?Key {
return switch (ch) { return switch (ch) {

View File

@ -14,6 +14,7 @@ const input = @import("../input.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig"); const terminal = @import("../terminal/main.zig");
const inspector = @import("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. /// The window names. These are used with docking so we need to have access.
const window_cell = "Cell"; const window_cell = "Cell";
@ -440,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void {
} }
{ {
_ = cimgui.c.igTableSetColumnIndex(1); _ = 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.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.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.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.igTableSetColumnIndex(1);
cimgui.c.igText( cimgui.c.igText(
"%d pt", "%.2f pt",
self.surface.font_size.points, self.surface.font_size.points,
); );
} }

View File

@ -3,6 +3,8 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const cimgui = @import("cimgui"); const cimgui = @import("cimgui");
const terminal = @import("../terminal/main.zig"); const terminal = @import("../terminal/main.zig");
const inspector = @import("main.zig");
const units = @import("units.zig");
pub fn render(page: *const terminal.Page) void { pub fn render(page: *const terminal.Page) void {
cimgui.c.igPushID_Ptr(page); cimgui.c.igPushID_Ptr(page);
@ -25,7 +27,7 @@ pub fn render(page: *const terminal.Page) void {
} }
{ {
_ = cimgui.c.igTableSetColumnIndex(1); _ = 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); 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 Kind = enum { print, execute, csi, esc, osc, dcs, apc };
const Metadata = std.StringHashMap([:0]const u8); 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( pub fn init(
alloc: Allocator, alloc: Allocator,
surface: *Surface, 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 => { else => {
@compileLog(T); @compileLog(T);
@compileError("unsupported type, see log"); @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.InvalidAction => try stderr.print(
"Error: unknown CLI action specified. CLI actions are specified with\n" ++ "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"), 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); 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 /// 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. /// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code. /// This is extracted from the Zig compiler source code.
pub fn fixMaxFiles() void { pub fn fixMaxFiles() ?rlimit {
if (!@hasDecl(posix.system, "rlimit")) return; 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", .{}); 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 we're already at the max, we're done.
if (lim.cur >= lim.max) { if (old.cur >= old.max) {
log.debug("file handle limit already maximized value={}", .{lim.cur}); log.debug("file handle limit already maximized value={}", .{old.cur});
return; return old;
} }
// Do a binary search for the limit. // Do a binary search for the limit.
var lim = old;
var min: posix.rlim_t = lim.cur; var min: posix.rlim_t = lim.cur;
var max: posix.rlim_t = 1 << 20; var max: posix.rlim_t = 1 << 20;
// But if there's a defined upper bound, don't search, just set it. // 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}); 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. /// 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 /// Determine the home directory for the currently executing user. This
/// is generally an expensive process so the value should be cached. /// 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) { return switch (builtin.os.tag) {
inline .linux, .macos => try homeUnix(buf), inline .linux, .macos => try homeUnix(buf),
.windows => try homeWindows(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. // First: if we have a HOME env var, then we use that.
if (posix.getenv("HOME")) |result| { if (posix.getenv("HOME")) |result| {
if (buf.len < result.len) return Error.BufferTooSmall; if (buf.len < result.len) return Error.BufferTooSmall;
@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 {
return null; return null;
} }
fn homeWindows(buf: []u8) !?[]u8 { fn homeWindows(buf: []u8) !?[]const u8 {
const drive_len = blk: { const drive_len = blk: {
var fba_instance = std.heap.FixedBufferAllocator.init(buf); var fba_instance = std.heap.FixedBufferAllocator.init(buf);
const fba = fba_instance.allocator(); const fba = fba_instance.allocator();
@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 {
return std.mem.trim(u8, input, " \n\t"); 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 { test {
const testing = std.testing; const testing = std.testing;

View File

@ -25,41 +25,26 @@ pub fn appSupportDir(
alloc: Allocator, alloc: Allocator,
sub_path: []const u8, sub_path: []const u8,
) AppSupportDirError![]const u8 { ) AppSupportDirError![]const u8 {
comptime assert(builtin.target.isDarwin()); return try commonDir(
alloc,
const NSFileManager = objc.getClass("NSFileManager").?; .NSApplicationSupportDirectory,
const manager = NSFileManager.msgSend( &.{ build_config.bundle_id, sub_path },
objc.Object,
objc.sel("defaultManager"),
.{},
); );
}
const url = manager.msgSend( pub const CacheDirError = Allocator.Error || error{AppleAPIFailed};
objc.Object,
objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), /// Return the path to the system cache directory with the given sub path joined.
.{ /// This allocates the result using the given allocator.
NSSearchPathDirectory.NSApplicationSupportDirectory, pub fn cacheDir(
NSSearchPathDomainMask.NSUserDomainMask, alloc: Allocator,
@as(?*anyopaque, null), sub_path: []const u8,
true, ) CacheDirError![]const u8 {
@as(?*anyopaque, null), 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{ pub const SetQosClassError = error{
@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct {
}; };
pub const NSSearchPathDirectory = enum(c_ulong) { pub const NSSearchPathDirectory = enum(c_ulong) {
NSCachesDirectory = 13,
NSApplicationSupportDirectory = 14, NSApplicationSupportDirectory = 14,
}; };
pub const NSSearchPathDomainMask = enum(c_ulong) { pub const NSSearchPathDomainMask = enum(c_ulong) {
NSUserDomainMask = 1, 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 setenv = env.setenv;
pub const unsetenv = env.unsetenv; pub const unsetenv = env.unsetenv;
pub const launchedFromDesktop = desktop.launchedFromDesktop; pub const launchedFromDesktop = desktop.launchedFromDesktop;
pub const desktopEnvironment = desktop.desktopEnvironment;
pub const rlimit = file.rlimit;
pub const fixMaxFiles = file.fixMaxFiles; pub const fixMaxFiles = file.fixMaxFiles;
pub const restoreMaxFiles = file.restoreMaxFiles;
pub const allocTmpDir = file.allocTmpDir; pub const allocTmpDir = file.allocTmpDir;
pub const freeTmpDir = file.freeTmpDir; pub const freeTmpDir = file.freeTmpDir;
pub const isFlatpak = flatpak.isFlatpak; pub const isFlatpak = flatpak.isFlatpak;
pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; pub const FlatpakHostCommand = flatpak.FlatpakHostCommand;
pub const home = homedir.home; pub const home = homedir.home;
pub const expandHome = homedir.expandHome;
pub const ensureLocale = locale.ensureLocale; pub const ensureLocale = locale.ensureLocale;
pub const clickInterval = mouse.clickInterval; pub const clickInterval = mouse.clickInterval;
pub const open = openpkg.open; pub const open = openpkg.open;

View File

@ -58,6 +58,15 @@ fn dir(
opts: Options, opts: Options,
internal_opts: InternalOptions, internal_opts: InternalOptions,
) ![]u8 { ) ![]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 // 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. // both whether we have the env var and whether we own it.
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME` // on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
@ -93,15 +102,6 @@ fn dir(
return try alloc.dupe(u8, env); 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 // Get our home dir
var buf: [1024]u8 = undefined; var buf: [1024]u8 = undefined;
if (try homedir.home(&buf)) |home| { 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 { test parseTerminalExec {
const testing = std.testing; const testing = std.testing;

View File

@ -360,6 +360,7 @@ pub const DerivedConfig = struct {
arena: ArenaAllocator, arena: ArenaAllocator,
font_thicken: bool, font_thicken: bool,
font_thicken_strength: u8,
font_features: std.ArrayListUnmanaged([:0]const u8), font_features: std.ArrayListUnmanaged([:0]const u8),
font_styles: font.CodepointResolver.StyleStatus, font_styles: font.CodepointResolver.StyleStatus,
cursor_color: ?terminal.color.RGB, cursor_color: ?terminal.color.RGB,
@ -410,6 +411,7 @@ pub const DerivedConfig = struct {
return .{ return .{
.background_opacity = @max(0, @min(1, config.@"background-opacity")), .background_opacity = @max(0, @min(1, config.@"background-opacity")),
.font_thicken = config.@"font-thicken", .font_thicken = config.@"font-thicken",
.font_thicken_strength = config.@"font-thicken-strength",
.font_features = font_features.list, .font_features = font_features.list,
.font_styles = font_styles, .font_styles = font_styles,
@ -2837,6 +2839,7 @@ fn addGlyph(
.{ .{
.grid_metrics = self.grid_metrics, .grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken, .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_text_end: u32 = 0,
image_virtual: bool = false, image_virtual: bool = false,
/// Defererred OpenGL operation to update the screen size. /// Deferred OpenGL operation to update the screen size.
const SetScreenSize = struct { const SetScreenSize = struct {
size: renderer.Size, size: renderer.Size,
@ -272,6 +272,7 @@ pub const DerivedConfig = struct {
arena: ArenaAllocator, arena: ArenaAllocator,
font_thicken: bool, font_thicken: bool,
font_thicken_strength: u8,
font_features: std.ArrayListUnmanaged([:0]const u8), font_features: std.ArrayListUnmanaged([:0]const u8),
font_styles: font.CodepointResolver.StyleStatus, font_styles: font.CodepointResolver.StyleStatus,
cursor_color: ?terminal.color.RGB, cursor_color: ?terminal.color.RGB,
@ -321,6 +322,7 @@ pub const DerivedConfig = struct {
return .{ return .{
.background_opacity = @max(0, @min(1, config.@"background-opacity")), .background_opacity = @max(0, @min(1, config.@"background-opacity")),
.font_thicken = config.@"font-thicken", .font_thicken = config.@"font-thicken",
.font_thicken_strength = config.@"font-thicken-strength",
.font_features = font_features.list, .font_features = font_features.list,
.font_styles = font_styles, .font_styles = font_styles,
@ -764,7 +766,7 @@ pub fn updateFrame(
// We used to share terminal state, but we've since learned through // We used to share terminal state, but we've since learned through
// analysis that it is faster to copy the terminal state than to // 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( var screen_copy = try state.terminal.screen.clone(
self.alloc, self.alloc,
.{ .viewport = .{} }, .{ .viewport = .{} },
@ -2093,6 +2095,7 @@ fn addGlyph(
.{ .{
.grid_metrics = self.grid_metrics, .grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken, .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. /// point remains valid even through scrolling without any additional work.
/// ///
/// A downside is that the pin is only valid until the pagelist is modified /// 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. /// erasing rows, etc.
/// ///
/// A pin can also be "tracked" which means that it will be updated as the /// A pin can also be "tracked" which means that it will be updated as the
@ -3389,9 +3389,9 @@ pub const Pin = struct {
else => {}, else => {},
} }
// Never extend cell that has a default background. // Never extend a cell that has a default background.
// A default background is if there is no background // A default background is applied if there is no background
// on the style OR the explicitly set background // on the style or the explicitly set background
// matches our default background. // matches our default background.
const s = self.style(cell); const s = self.style(cell);
const bg = s.bg(cell, palette) orelse return true; 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 // 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 // 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 // bottom is another page then it means that the range is
// at least the full top page and since we're the same page // at least the full top page and since we're the same page
// we're in the range. // 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 false;
if (self.y < bottom.y) return true; 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. // or equal to the bottom x.
assert(self.y == bottom.y); assert(self.y == bottom.y);
return self.x <= bottom.x; 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.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long", error.FilePathTooLong => r.message = "EINVAL: file path too long",
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", 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.UnsupportedFormat => r.message = "EINVAL: unsupported format",
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",

View File

@ -220,6 +220,9 @@ pub const LoadingImage = struct {
// Temporary file logic // Temporary file logic
if (medium == .temporary_file) { if (medium == .temporary_file) {
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) {
return error.TemporaryFileNotNamedCorrectly;
}
} }
defer if (medium == .temporary_file) { defer if (medium == .temporary_file) {
posix.unlink(path) catch |err| { posix.unlink(path) catch |err| {
@ -469,6 +472,7 @@ pub const Image = struct {
DimensionsTooLarge, DimensionsTooLarge,
FilePathTooLong, FilePathTooLong,
TemporaryFileNotInTempDir, TemporaryFileNotInTempDir,
TemporaryFileNotNamedCorrectly,
UnsupportedFormat, UnsupportedFormat,
UnsupportedMedium, UnsupportedMedium,
UnsupportedDepth, UnsupportedDepth,
@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
try testing.expect(img.compression == .none); 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 testing = std.testing;
const alloc = testing.allocator; 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; 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("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 = .{ var cmd: command.Command = .{
.control = .{ .transmit = .{ .control = .{ .transmit = .{
.format = .rgb, .format = .rgb,
@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" {
defer tmp_dir.deinit(); defer tmp_dir.deinit();
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
try tmp_dir.dir.writeFile(.{ try tmp_dir.dir.writeFile(.{
.sub_path = "image.data", .sub_path = "tty-graphics-protocol-image.data",
.data = data, .data = data,
}); });
var buf: [std.fs.max_path_bytes]u8 = undefined; 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 = .{ var cmd: command.Command = .{
.control = .{ .transmit = .{ .control = .{ .transmit = .{

View File

@ -163,6 +163,15 @@ pub const Command = union(enum) {
duration_ms: u16, 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) /// Set progress state (OSC 9;4)
progress: struct { progress: struct {
state: ProgressState, state: ProgressState,
@ -360,6 +369,9 @@ pub const Parser = struct {
// ConEmu specific substates // ConEmu specific substates
conemu_sleep, conemu_sleep,
conemu_sleep_value, conemu_sleep_value,
conemu_message_box,
conemu_tab,
conemu_tab_txt,
conemu_progress_prestate, conemu_progress_prestate,
conemu_progress_state, conemu_progress_state,
conemu_progress_prevalue, conemu_progress_prevalue,
@ -787,6 +799,12 @@ pub const Parser = struct {
'1' => { '1' => {
self.state = .conemu_sleep; self.state = .conemu_sleep;
}, },
'2' => {
self.state = .conemu_message_box;
},
'3' => {
self.state = .conemu_tab;
},
'4' => { '4' => {
self.state = .conemu_progress_prestate; self.state = .conemu_progress_prestate;
}, },
@ -808,10 +826,38 @@ pub const Parser = struct {
else => self.state = .invalid, 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) { .conemu_sleep_value => switch (c) {
else => self.complete = true, 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) { .conemu_progress_prestate => switch (c) {
';' => { ';' => {
self.command = .{ .progress = .{ self.command = .{ .progress = .{
@ -1759,6 +1805,110 @@ test "OSC: show desktop notification with title" {
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); 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" { test "OSC: OSC9 progress set" {
const testing = std.testing; 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 { fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void {
switch (input.final) { switch (input.final) {
// CUU - Cursor Up // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -392,8 +393,15 @@ pub fn Stream(comptime Handler: type) type {
false, false,
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI A with intermediates: {s}",
.{input.intermediates},
),
},
// CUD - Cursor Down // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -405,8 +413,15 @@ pub fn Stream(comptime Handler: type) type {
false, false,
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI B with intermediates: {s}",
.{input.intermediates},
),
},
// CUF - Cursor Right // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI C with intermediates: {s}",
.{input.intermediates},
),
},
// CUB - Cursor Left // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI D with intermediates: {s}",
.{input.intermediates},
),
},
// CNL - Cursor Next Line // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -442,8 +471,15 @@ pub fn Stream(comptime Handler: type) type {
true, true,
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI E with intermediates: {s}",
.{input.intermediates},
),
},
// CPL - Cursor Previous Line // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -455,25 +491,46 @@ pub fn Stream(comptime Handler: type) type {
true, true,
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI F with intermediates: {s}",
.{input.intermediates},
),
},
// HPA - Cursor Horizontal Position Absolute // HPA - Cursor Horizontal Position Absolute
// TODO: test // 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), 0 => try self.handler.setCursorCol(1),
1 => try self.handler.setCursorCol(input.params[0]), 1 => try self.handler.setCursorCol(input.params[0]),
else => log.warn("invalid HPA command: {}", .{input}), else => log.warn("invalid HPA command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI G with intermediates: {s}",
.{input.intermediates},
),
},
// CUP - Set Cursor Position. // CUP - Set Cursor Position.
// TODO: test // 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), 0 => try self.handler.setCursorPos(1, 1),
1 => try self.handler.setCursorPos(input.params[0], 1), 1 => try self.handler.setCursorPos(input.params[0], 1),
2 => try self.handler.setCursorPos(input.params[0], input.params[1]), 2 => try self.handler.setCursorPos(input.params[0], input.params[1]),
else => log.warn("invalid CUP command: {}", .{input}), else => log.warn("invalid CUP command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI H with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI I with intermediates: {s}",
.{input.intermediates},
),
},
// Erase Display // Erase Display
'J' => if (@hasDecl(T, "eraseDisplay")) { 'J' => if (@hasDecl(T, "eraseDisplay")) {
const protected_: ?bool = switch (input.intermediates.len) { const protected_: ?bool = switch (input.intermediates.len) {
@ -540,22 +603,37 @@ pub fn Stream(comptime Handler: type) type {
// IL - Insert Lines // IL - Insert Lines
// TODO: test // 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), 0 => try self.handler.insertLines(1),
1 => try self.handler.insertLines(input.params[0]), 1 => try self.handler.insertLines(input.params[0]),
else => log.warn("invalid IL command: {}", .{input}), else => log.warn("invalid IL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI L with intermediates: {s}",
.{input.intermediates},
),
},
// DL - Delete Lines // DL - Delete Lines
// TODO: test // 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), 0 => try self.handler.deleteLines(1),
1 => try self.handler.deleteLines(input.params[0]), 1 => try self.handler.deleteLines(input.params[0]),
else => log.warn("invalid DL command: {}", .{input}), else => log.warn("invalid DL command: {}", .{input}),
} else log.warn("unimplemented CSI callback: {}", .{input}), } else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI M with intermediates: {s}",
.{input.intermediates},
),
},
// Delete Character (DCH) // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI P with intermediates: {s}",
.{input.intermediates},
),
},
// Scroll Up (SD) // Scroll Up (SD)
'S' => switch (input.intermediates.len) { 'S' => switch (input.intermediates.len) {
@ -587,7 +671,8 @@ pub fn Stream(comptime Handler: type) type {
}, },
// Scroll Down (SD) // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 1 => input.params[0],
@ -598,27 +683,31 @@ pub fn Stream(comptime Handler: type) type {
}, },
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
// Cursor Tabulation Control else => log.warn(
'W' => { "ignoring unimplemented CSI T with intermediates: {s}",
switch (input.params.len) { .{input.intermediates},
0 => if (@hasDecl(T, "tabSet")) ),
try self.handler.tabSet() },
else
log.warn("unimplemented tab set callback: {}", .{input}),
1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') { // Cursor Tabulation Control
if (input.params[0] == 5) { 'W' => switch (input.intermediates.len) {
if (@hasDecl(T, "tabReset")) 0 => {
try self.handler.tabReset() if (input.params.len == 0 or
else (input.params.len == 1 and input.params[0] == 0))
log.warn("unimplemented tab reset callback: {}", .{input}); {
} else log.warn("invalid cursor tabulation control: {}", .{input}); if (@hasDecl(T, "tabSet"))
} else {
switch (input.params[0]) {
0 => if (@hasDecl(T, "tabSet"))
try self.handler.tabSet() try self.handler.tabSet()
else 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")) 2 => if (@hasDecl(T, "tabClear"))
try self.handler.tabClear(.current) try self.handler.tabClear(.current)
@ -631,7 +720,6 @@ pub fn Stream(comptime Handler: type) type {
log.warn("unimplemented tab clear callback: {}", .{input}), log.warn("unimplemented tab clear callback: {}", .{input}),
else => {}, else => {},
}
}, },
else => {}, else => {},
@ -641,8 +729,22 @@ pub fn Stream(comptime Handler: type) type {
return; 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) // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI X with intermediates: {s}",
.{input.intermediates},
),
},
// CHT - Cursor Horizontal Tabulation Back // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI Z with intermediates: {s}",
.{input.intermediates},
),
},
// HPR - Cursor Horizontal Position Relative // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI a with intermediates: {s}",
.{input.intermediates},
),
},
// Repeat Previous Char (REP) // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI b with intermediates: {s}",
.{input.intermediates},
),
},
// c - Device Attributes (DA1) // c - Device Attributes (DA1)
'c' => if (@hasDecl(T, "deviceAttributes")) { 'c' => if (@hasDecl(T, "deviceAttributes")) {
const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) { 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}), } else log.warn("unimplemented CSI callback: {}", .{input}),
// VPA - Cursor Vertical Position Absolute // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI d with intermediates: {s}",
.{input.intermediates},
),
},
// VPR - Cursor Vertical Position Relative // 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) { switch (input.params.len) {
0 => 1, 0 => 1,
1 => input.params[0], 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("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI e with intermediates: {s}",
.{input.intermediates},
),
},
// TBC - Tab Clear // TBC - Tab Clear
// TODO: test // 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) { switch (input.params.len) {
1 => @enumFromInt(input.params[0]), 1 => @enumFromInt(input.params[0]),
else => { else => {
@ -743,6 +887,12 @@ pub fn Stream(comptime Handler: type) type {
}, },
) else log.warn("unimplemented CSI callback: {}", .{input}), ) else log.warn("unimplemented CSI callback: {}", .{input}),
else => log.warn(
"ignoring unimplemented CSI g with intermediates: {s}",
.{input.intermediates},
),
},
// SM - Set Mode // SM - Set Mode
'h' => if (@hasDecl(T, "setMode")) mode: { 'h' => if (@hasDecl(T, "setMode")) mode: {
const ansi_mode = ansi: { const ansi_mode = ansi: {
@ -1455,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd}); } else log.warn("unimplemented OSC callback: {}", .{cmd});
}, },
.progress, .sleep => { .progress, .sleep, .show_message_box, .change_conemu_tab_title => {
log.warn("unimplemented OSC callback: {}", .{cmd}); log.warn("unimplemented OSC callback: {}", .{cmd});
}, },
} }
@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// HTS - Horizontal Tab Set // HTS - Horizontal Tab Set
'H' => if (@hasDecl(T, "tabSet")) 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) {
try self.handler.tabSet() 0 => try self.handler.tabSet(),
else else => {
log.warn("unimplemented tab set callback: {}", .{action}), log.warn("invalid tab set command: {}", .{action});
return;
},
} else log.warn("unimplemented tab set callback: {}", .{action}),
// RI - Reverse Index // RI - Reverse Index
'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { '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}), } else log.warn("unimplemented invokeCharset: {}", .{action}),
// SPA - Start of Guarded Area // 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); try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// EPA - End of Guarded Area // 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); try self.handler.setProtectedMode(ansi.ProtectedMode.off);
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
// DECID // DECID
'Z' => if (@hasDecl(T, "deviceAttributes")) { 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) {
try self.handler.deviceAttributes(.primary, &.{}); try self.handler.deviceAttributes(.primary, &.{});
} else log.warn("unimplemented ESC callback: {}", .{action}), } else log.warn("unimplemented ESC callback: {}", .{action}),
@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented invokeCharset: {}", .{action}), } else log.warn("unimplemented invokeCharset: {}", .{action}),
// Set application keypad mode // Set application keypad mode
'=' => if (@hasDecl(T, "setMode")) { '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, true); try self.handler.setMode(.keypad_keys, true);
} else log.warn("unimplemented setMode: {}", .{action}), } else log.warn("unimplemented setMode: {}", .{action}),
// Reset application keypad mode // Reset application keypad mode
'>' => if (@hasDecl(T, "setMode")) { '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) {
try self.handler.setMode(.keypad_keys, false); try self.handler.setMode(.keypad_keys, false);
} else log.warn("unimplemented setMode: {}", .{action}), } else log.warn("unimplemented setMode: {}", .{action}),
@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" {
s.handler.amount = 0; s.handler.amount = 0;
try s.nextSlice("\x1B[5;4C"); try s.nextSlice("\x1B[5;4C");
try testing.expectEqual(@as(u16, 0), s.handler.amount); 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)" { 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 s.nextSlice("\x1B[?6l");
try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); 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)" { 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 s.nextSlice("\x1B[4l");
try testing.expect(s.handler.mode == null); 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" { 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.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?);
try testing.expect(!s.handler.protected.?); 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" { test "stream: DECEL, DECSEL" {
@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" {
try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?);
try testing.expect(!s.handler.protected.?); 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" { test "stream: DECSCUSR" {
@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" {
try s.nextSlice("\x1B[1 q"); try s.nextSlice("\x1B[1 q");
try testing.expect(s.handler.style.? == .blinking_block); 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" { test "stream: DECSCUSR without space" {
@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" {
try s.nextSlice("\x1B[>1s"); try s.nextSlice("\x1B[>1s");
try testing.expect(s.handler.escape.? == true); 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" { test "stream: change window title with invalid utf-8" {
@ -2374,6 +2559,14 @@ test "stream CSI W tab set" {
s.handler.called = false; s.handler.called = false;
try s.nextSlice("\x1b[0W"); try s.nextSlice("\x1b[0W");
try testing.expect(s.handler.called); 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" { test "stream CSI ? W reset tab stops" {
@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?5W"); try s.nextSlice("\x1b[?5W");
try testing.expect(s.handler.reset); 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. // for alt screen, we do nothing.
if (self.terminal.active_screen == .alternate) return; if (self.terminal.active_screen == .alternate) return;
// Clear our selection
self.terminal.screen.clearSelection();
// Clear our scrollback // Clear our scrollback
if (history) self.terminal.eraseDisplay(.scrollback, false); if (history) self.terminal.eraseDisplay(.scrollback, false);

View File

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