aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/apprt
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/apprt
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/apprt')
-rw-r--r--vendor/libghostty-vt/src/apprt/action.zig99
-rw-r--r--vendor/libghostty-vt/src/apprt/embedded.zig475
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/App.zig2
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/Surface.zig4
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/adw_version.zig26
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig94
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig33
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class.zig28
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/application.zig756
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig5
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig10
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig71
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig19
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig291
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface.zig524
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig4
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig12
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/tab.zig4
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig3
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/window.zig147
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/css/style.css54
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig6
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig42
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig9
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig71
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/key.zig120
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/media.zig7
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/portal.zig4
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig43
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/post_fork.zig9
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig9
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp4
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp7
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp72
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp9
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp37
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig17
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto.zig23
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig13
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig35
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig3
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig165
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml305
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig13
-rw-r--r--vendor/libghostty-vt/src/apprt/ipc.zig100
-rw-r--r--vendor/libghostty-vt/src/apprt/structs.zig133
-rw-r--r--vendor/libghostty-vt/src/apprt/surface.zig134
47 files changed, 3381 insertions, 670 deletions
diff --git a/vendor/libghostty-vt/src/apprt/action.zig b/vendor/libghostty-vt/src/apprt/action.zig
index 4728d73c..7e902e56 100644
--- a/vendor/libghostty-vt/src/apprt/action.zig
+++ b/vendor/libghostty-vt/src/apprt/action.zig
@@ -8,6 +8,7 @@ const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const CoreSurface = @import("../Surface.zig");
const lib = @import("../lib/main.zig");
+const compat_testing = @import("../lib/compat/testing.zig");
/// The target for an action. This is generally the thing that had focus
/// while the action was made but the concept of "focus" is not guaranteed
@@ -195,6 +196,9 @@ pub const Action = union(Key) {
/// rendered at the next opportunity.
render_inspector,
+ /// Export the Terminal IO inspector event log.
+ export_terminal_io: ExportTerminalIO,
+
/// Show a desktop notification.
desktop_notification: DesktopNotification,
@@ -204,6 +208,9 @@ pub const Action = union(Key) {
/// Set the tab title override for the target's tab.
set_tab_title: SetTitle,
+ /// Set the window title override for the target's tab.
+ set_window_title: SetTitle,
+
/// Set the title of the target to a prompted value. It is up to
/// the apprt to prompt. The value specifies whether to prompt for the
/// surface title or the tab title.
@@ -227,7 +234,7 @@ pub const Action = union(Key) {
/// Open the Ghostty configuration. This is platform-specific about
/// what it means; it can mean opening a dedicated UI or just opening
/// a file in a text editor.
- open_config,
+ open_config: OpenConfig,
/// Called when there are no more surfaces and the app should quit
/// after the configured delay.
@@ -347,6 +354,9 @@ pub const Action = union(Key) {
/// otherwise the terminal-set title.
copy_title_to_clipboard,
+ /// Move a tab to a new window.
+ move_tab_to_new_window,
+
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -380,9 +390,11 @@ pub const Action = union(Key) {
inspector,
show_gtk_inspector,
render_inspector,
+ export_terminal_io,
desktop_notification,
set_title,
set_tab_title,
+ set_window_title,
prompt_title,
pwd,
mouse_shape,
@@ -415,6 +427,7 @@ pub const Action = union(Key) {
search_selected,
readonly,
copy_title_to_clipboard,
+ move_tab_to_new_window,
test "ghostty.h Action.Key" {
try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_");
@@ -424,8 +437,11 @@ pub const Action = union(Key) {
/// Sync with: ghostty_action_u
pub const CValue = cvalue: {
const key_fields = @typeInfo(Key).@"enum".fields;
- var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined;
- for (key_fields, 0..) |field, i| {
+ var names: [key_fields.len][]const u8 = undefined;
+ var types: [key_fields.len]type = undefined;
+ var attrs: [key_fields.len]std.builtin.Type.UnionField.Attributes = undefined;
+
+ for (key_fields, &names, &types, &attrs) |field, *name, *ty, *attr| {
const action = @unionInit(Action, field.name, undefined);
const Type = t: {
const Type = @TypeOf(@field(action, field.name));
@@ -434,19 +450,12 @@ pub const Action = union(Key) {
break :t Type;
};
- union_fields[i] = .{
- .name = field.name,
- .type = Type,
- .alignment = @alignOf(Type),
- };
+ name.* = field.name;
+ ty.* = Type;
+ attr.* = .{ .@"align" = @alignOf(Type) };
}
- break :cvalue @Type(.{ .@"union" = .{
- .layout = .@"extern",
- .tag_type = null,
- .fields = &union_fields,
- .decls = &.{},
- } });
+ break :cvalue @Union(.@"extern", null, &names, &types, &attrs);
};
/// Sync with: ghostty_action_s
@@ -460,7 +469,7 @@ pub const Action = union(Key) {
// At the time of writing, we don't promise ABI compatibility
// so we can change this but I want to be aware of it.
assert(@sizeOf(CValue) == switch (@sizeOf(usize)) {
- 4 => 16,
+ 4 => 24,
8 => 24,
else => unreachable,
});
@@ -615,6 +624,37 @@ pub const Inspector = enum(c_int) {
}
};
+/// Terminal IO inspector contents to export. The contents are only valid for
+/// the duration of the action callback.
+pub const ExportTerminalIO = struct {
+ contents: []const u8,
+
+ // Sync with: ghostty_action_export_terminal_io_s
+ pub const C = extern struct {
+ contents: [*]const u8,
+ len: usize,
+ };
+
+ pub fn cval(self: ExportTerminalIO) C {
+ return .{
+ .contents = self.contents.ptr,
+ .len = self.contents.len,
+ };
+ }
+
+ pub fn format(
+ value: @This(),
+ comptime _: []const u8,
+ _: std.fmt.Options,
+ writer: *std.Io.Writer,
+ ) !void {
+ try writer.print(
+ "{s}{{ contents: {d} bytes }}",
+ .{ @typeName(@This()), value.contents.len },
+ );
+ }
+};
+
pub const QuitTimer = enum(c_int) {
start,
stop,
@@ -642,10 +682,11 @@ pub const MouseVisibility = enum(c_int) {
}
};
-/// Whether to prompt for the surface title or tab title.
+/// Whether to prompt for the surface, tab, or window title.
pub const PromptTitle = enum(c_int) {
surface,
tab,
+ window,
test "ghostty.h PromptTitle" {
try lib.checkGhosttyHEnum(PromptTitle, "GHOSTTY_PROMPT_TITLE_");
@@ -713,7 +754,7 @@ pub const SetTitle = struct {
pub fn format(
value: @This(),
comptime _: []const u8,
- _: std.fmt.FormatOptions,
+ _: std.fmt.Options,
writer: *std.Io.Writer,
) !void {
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.title });
@@ -737,7 +778,7 @@ pub const Pwd = struct {
pub fn format(
value: @This(),
comptime _: []const u8,
- _: std.fmt.FormatOptions,
+ _: std.fmt.Options,
writer: *std.Io.Writer,
) !void {
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.pwd });
@@ -765,7 +806,7 @@ pub const DesktopNotification = struct {
pub fn format(
value: @This(),
comptime _: []const u8,
- _: std.fmt.FormatOptions,
+ _: std.fmt.Options,
writer: *std.Io.Writer,
) !void {
try writer.print("{s}{{ title: {s}, body: {s} }}", .{
@@ -909,6 +950,11 @@ pub const OpenUrl = struct {
/// The URL is known to contain HTML content.
html,
+ /// The URL came from an OSC 8 hyperlink. Application runtimes should
+ /// treat this as untrusted terminal output and apply a platform-specific
+ /// safe-opening policy.
+ osc8,
+
test "ghostty.h OpenUrl.Kind" {
try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_");
}
@@ -1007,6 +1053,19 @@ pub const SearchSelected = struct {
}
};
+/// sync with ghostty_action_close_tab_mode_e in ghostty.h
+pub const OpenConfig = enum(c_int) {
+ /// Open the config in the OS default editor.
+ os_open,
+
+ /// Open the config in a new window using $EDITOR or $VISUAL
+ new_window,
+
+ test "ghostty.h OpenConfig" {
+ try lib.checkGhosttyHEnum(OpenConfig, "GHOSTTY_ACTION_OPEN_CONFIG_");
+ }
+};
+
test {
- _ = std.testing.refAllDeclsRecursive(@This());
+ _ = compat_testing.refAllDeclsRecursive(@This());
}
diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig
index 7310159c..525bc856 100644
--- a/vendor/libghostty-vt/src/apprt/embedded.zig
+++ b/vendor/libghostty-vt/src/apprt/embedded.zig
@@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator;
const objc = @import("objc");
const apprt = @import("../apprt.zig");
const font = @import("../font/main.zig");
+const global = @import("../global.zig");
const input = @import("../input.zig");
const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
@@ -51,18 +52,36 @@ pub const App = struct {
/// Callback called to handle an action.
action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool,
- /// Read the clipboard value. Returns true if the clipboard request
- /// was started and complete_clipboard_request may be called with the
- /// given state pointer. Returns false if the clipboard request couldn't
- /// be started (such as when no text is available for a paste request).
- read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool,
+ /// Read the clipboard value. The result only reports facts about
+ /// the clipboard: whether the request was started (in which case
+ /// complete_clipboard_request or deny_clipboard_request must
+ /// eventually be called with the given state pointer), whether the
+ /// clipboard has no servable contents, or whether the clipboard
+ /// can't be read at all. How each non-started state is answered is
+ /// up to the core.
+ ///
+ /// The MIME types are exactly the representations the caller wants
+ /// served; the embedder should read only those. Text-like types
+ /// are always requested as the canonical "text/plain". The final
+ /// bool asks for the listing of all MIME types available on the
+ /// clipboard to be delivered with the completion.
+ read_clipboard: *const fn (
+ SurfaceUD,
+ c_int,
+ *apprt.ClipboardRequest,
+ [*]const [*:0]const u8,
+ usize,
+ bool,
+ ) callconv(.c) apprt.ClipboardReadResult,
/// This may be called after a read clipboard call to request
- /// confirmation that the clipboard value is safe to read. The embedder
- /// must call complete_clipboard_request with the given request.
+ /// confirmation that the clipboard value is safe to read. The
+ /// embedder must call complete_clipboard_request (usually with
+ /// the confirmation's contents, which are only borrowed for
+ /// this call) or deny_clipboard_request with the given request.
confirm_read_clipboard: *const fn (
SurfaceUD,
- [*:0]const u8,
+ *const CAPI.ClipboardConfirm,
*apprt.ClipboardRequest,
apprt.ClipboardRequestType,
) callconv(.c) void,
@@ -119,7 +138,13 @@ pub const App = struct {
core_app: *CoreApp,
opts: Options,
- keymap: input.Keymap,
+
+ /// The keyboard layout keymap. This is lazily initialized on first
+ /// use because creating it requires talking to the text input
+ /// system (TIS on macOS), and the first such call in a process is
+ /// slow (multiple milliseconds). It is only needed once keyboard
+ /// events start flowing, at which point the system is warm.
+ keymap: ?input.Keymap,
/// The configuration for the app. This is owned by this structure.
config: Config,
@@ -135,19 +160,16 @@ pub const App = struct {
var config_clone = try config.clone(alloc);
errdefer config_clone.deinit();
- var keymap = try input.Keymap.init();
- errdefer keymap.deinit();
-
self.* = .{
.core_app = core_app,
.config = config_clone,
.opts = opts,
- .keymap = keymap,
+ .keymap = null,
};
}
pub fn terminate(self: *App) void {
- self.keymap.deinit();
+ if (self.keymap) |*v| v.deinit();
self.config.deinit();
}
@@ -207,8 +229,10 @@ pub const App = struct {
/// This should be called whenever the keyboard layout was changed.
pub fn reloadKeymap(self: *App) !void {
- // Reload the keymap
- try self.keymap.reload();
+ // Reload the keymap. If it was never initialized we don't need
+ // to do anything since lazy initialization will pick up the
+ // current layout.
+ if (self.keymap) |*v| try v.reload();
}
/// Loads the keyboard layout.
@@ -216,13 +240,25 @@ pub const App = struct {
/// Kind of expensive so this should be avoided if possible. When I say
/// "kind of expensive" I mean that its not something you probably want
/// to run on every keypress.
- pub fn keyboardLayout(self: *const App) input.KeyboardLayout {
+ pub fn keyboardLayout(self: *App) input.KeyboardLayout {
// We only support keyboard layout detection on macOS.
if (comptime builtin.os.tag != .macos) return .unknown;
+ // Lazily initialize the keymap.
+ const keymap: *input.Keymap = keymap: {
+ if (self.keymap == null) {
+ self.keymap = input.Keymap.init() catch |err| {
+ log.warn("error initializing keymap err={}", .{err});
+ return .unknown;
+ };
+ }
+
+ break :keymap &self.keymap.?;
+ };
+
// Any layout larger than this is not something we can handle.
var buf: [256]u8 = undefined;
- const id = self.keymap.sourceId(&buf) catch |err| {
+ const id = keymap.sourceId(&buf) catch |err| {
comptime assert(@TypeOf(err) == error{OutOfMemory});
return .unknown;
};
@@ -333,9 +369,10 @@ pub const App = struct {
_: apprt.ipc.Target,
comptime action: apprt.ipc.Action.Key,
_: apprt.ipc.Action.Value(action),
- ) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
+ ) (Allocator.Error || apprt.ipc.Errors)!bool {
switch (action) {
.new_window => return false,
+ .new_tab => return false,
.toggle_quick_terminal => return false,
}
}
@@ -372,7 +409,7 @@ pub const Platform = union(PlatformTag) {
/// Initialize a Platform a tag and configuration from the C ABI.
pub fn init(tag_int: c_int, c_platform: C) !Platform {
- const tag = try std.meta.intToEnum(PlatformTag, tag_int);
+ const tag = std.enums.fromInt(PlatformTag, tag_int) orelse return error.InvalidEnumTag;
return switch (tag) {
.macos => if (MacOS != void) macos: {
const config = c_platform.macos;
@@ -490,16 +527,16 @@ pub const Surface = struct {
if (opts.working_directory) |c_wd| {
const wd = std.mem.sliceTo(c_wd, 0);
if (wd.len > 0) wd: {
- var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
+ var dir = std.Io.Dir.openDirAbsolute(global.io(), wd, .{}) catch |err| {
log.warn(
"error opening requested working directory dir={s} err={}",
.{ wd, err },
);
break :wd;
};
- defer dir.close();
+ defer dir.close(global.io());
- const stat = dir.stat() catch |err| {
+ const stat = dir.stat(global.io()) catch |err| {
log.warn(
"failed to stat requested working directory dir={s} err={}",
.{ wd, err },
@@ -665,7 +702,13 @@ pub const Surface = struct {
) bool {
return switch (clipboard_type) {
.standard => true,
- .selection, .primary => self.app.opts.supports_selection_clipboard,
+ .selection => self.app.opts.supports_selection_clipboard,
+
+ // No embedder can serve the primary selection: the embedding
+ // API has no option to declare support for it, and macOS (the
+ // only GUI embedder) has no primary selection. The selection
+ // flag above covers a distinct custom pasteboard, not this.
+ .primary => false,
};
}
@@ -673,7 +716,67 @@ pub const Surface = struct {
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
- ) !bool {
+ ) !apprt.ClipboardReadResult {
+ // Kitty clipboard writes carry their own contents and read
+ // nothing from the clipboard, so they skip the read callback
+ // entirely: the request is completed immediately, and a
+ // completion that requires confirmation diverts into the
+ // apprt confirmation flow just like a read.
+ if (state == .kitty_write) {
+ // A write to a location the embedder can't serve is
+ // rejected before the transaction completes so the caller
+ // answers ENOSYS without a permission prompt, matching
+ // reads, where the embedder reports an unsupported
+ // location from the read callback. The write callback only
+ // fires after any prompt, so it is too late to reject.
+ if (!self.supportsClipboard(clipboard_type)) return .unsupported;
+
+ const alloc = self.app.core_app.alloc;
+ const state_ptr = try alloc.create(apprt.ClipboardRequest);
+ errdefer alloc.destroy(state_ptr);
+ state_ptr.* = state;
+ self.completeKittyClipboardWrite(state_ptr);
+ return .started;
+ }
+
+ // The representations the read wants served. Text-only
+ // requesters ask for the canonical text type; Kitty clipboard
+ // reads ask for exactly what the program requested, with
+ // text-like aliases normalized so the embedder never has to
+ // know about them.
+ var mimes_buf: [terminal.kitty.clipboard.max_read_mimes][*:0]const u8 = undefined;
+ const mimes: []const [*:0]const u8 = switch (state) {
+ .paste, .osc_52_read => &.{"text/plain"},
+
+ // A mode 5522 paste event only lists types and must not read
+ // any clipboard data.
+ .list => &.{},
+
+ .kitty_read => |kitty| mimes: {
+ assert(kitty.mimes.len <= mimes_buf.len);
+ for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| {
+ dst.* = if (terminal.clipboard.isTextMime(mime))
+ "text/plain"
+ else
+ mime.ptr;
+ }
+ break :mimes mimes_buf[0..kitty.mimes.len];
+ },
+
+ // Handled above.
+ .kitty_write => unreachable,
+
+ // No clipboard write code paths travel through this function
+ .osc_52_write => unreachable,
+ };
+ const list = switch (state) {
+ // Paste events need the full MIME listing without reading any
+ // representation. Kitty reads only ask for it when requested.
+ .list => true,
+ .kitty_read => |kitty| kitty.list,
+ else => false,
+ };
+
// We need to allocate to get a pointer to store our clipboard request
// so that it is stable until the read_clipboard callback and call
// complete_clipboard_request. This sucks but clipboard requests aren't
@@ -683,40 +786,156 @@ pub const Surface = struct {
errdefer alloc.destroy(state_ptr);
state_ptr.* = state;
- const started = self.app.opts.read_clipboard(
+ const result = self.app.opts.read_clipboard(
self.userdata,
@intCast(@intFromEnum(clipboard_type)),
state_ptr,
+ mimes.ptr,
+ mimes.len,
+ list,
);
- if (!started) {
- alloc.destroy(state_ptr);
- return false;
- }
- return true;
+ // Only a started request completes later and keeps the state.
+ if (result != .started) alloc.destroy(state_ptr);
+ return result;
}
- fn completeClipboardRequest(
+ /// Complete a Kitty clipboard protocol write request. The apprt
+ /// contributes nothing to the completion (the authoritative
+ /// contents live in the request itself), but a completion that
+ /// requires confirmation diverts into the confirm callback, which
+ /// keeps the state alive for the asynchronous prompt.
+ fn completeKittyClipboardWrite(
self: *Surface,
- str: [:0]const u8,
state: *apprt.ClipboardRequest,
- confirmed: bool,
) void {
const alloc = self.app.core_app.alloc;
+ const kitty = state.kitty_write;
- // Attempt to complete the request, but we may request
- // confirmation.
self.core_surface.completeClipboardRequest(
state.*,
- str,
- confirmed,
+ .{},
) catch |err| switch (err) {
+ error.UnauthorizedPaste => {
+ // Convert the would-be contents so the permission
+ // prompt can display exactly what would be written.
+ var stack = std.heap.stackFallback(1024, alloc);
+ const conv_alloc = stack.get();
+ const contents = conv_alloc.alloc(
+ CAPI.ClipboardContent,
+ kitty.contents.len,
+ ) catch |alloc_err| {
+ log.err("error confirming clipboard request err={}", .{alloc_err});
+ self.core_surface.denyClipboardRequest(state.*);
+ alloc.destroy(state);
+ return;
+ };
+ defer conv_alloc.free(contents);
+ for (kitty.contents, contents) |src, *dst| dst.* = .{
+ .mime = src.mime,
+ .data = src.data.ptr,
+ .len = src.data.len,
+ };
+
+ self.app.opts.confirm_read_clipboard(
+ self.userdata,
+ &.{
+ .contents = contents.ptr,
+ .contents_len = contents.len,
+ .available = null,
+ .available_len = 0,
+ .name = if (kitty.name.len > 0) kitty.name.ptr else null,
+ .can_remember = kitty.pw.len > 0,
+ },
+ state,
+ state.*,
+ );
+
+ return;
+ },
+
+ else => log.err("error completing clipboard request err={}", .{err}),
+ };
+
+ // We don't defer this because the clipboard confirmation route
+ // preserves the clipboard request.
+ alloc.destroy(state);
+ }
+
+ fn completeClipboardRequest(
+ self: *Surface,
+ complete: *const CAPI.ClipboardComplete,
+ state: *apprt.ClipboardRequest,
+ ) void {
+ const alloc = self.app.core_app.alloc;
+
+ // Convert the C representations to the core types. Everything
+ // remains borrowed from the caller for the duration of the call.
+ var stack = std.heap.stackFallback(1024, alloc);
+ const conv_alloc = stack.get();
+
+ const raw_contents: []const CAPI.ClipboardContent =
+ if (complete.contents) |v| v[0..complete.contents_len] else &.{};
+ const contents = conv_alloc.alloc(
+ terminal.clipboard.Content,
+ raw_contents.len,
+ ) catch |err| {
+ log.err("error completing clipboard request err={}", .{err});
+ alloc.destroy(state);
+ return;
+ };
+ defer conv_alloc.free(contents);
+ for (raw_contents, contents) |raw, *content| content.* = .{
+ .mime = std.mem.sliceTo(raw.mime, 0),
+ .data = raw.data[0..raw.len],
+ };
+
+ const raw_available: []const [*:0]const u8 =
+ if (complete.available) |v| v[0..complete.available_len] else &.{};
+ const available = conv_alloc.alloc(
+ []const u8,
+ raw_available.len,
+ ) catch |err| {
+ log.err("error completing clipboard request err={}", .{err});
+ alloc.destroy(state);
+ return;
+ };
+ defer conv_alloc.free(available);
+ for (raw_available, available) |raw, *mime| {
+ mime.* = std.mem.sliceTo(raw, 0);
+ }
+
+ // Attempt to complete the request, but we may request
+ // confirmation.
+ self.core_surface.completeClipboardRequest(state.*, .{
+ .contents = contents,
+ .available = available,
+ .confirmed = complete.confirmed,
+ .remember = complete.remember,
+ }) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
=> {
+ // Session grant information for the permission prompt,
+ // carried only by Kitty clipboard protocol requests.
+ const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) {
+ inline .kitty_read, .kitty_write => |kitty| .{
+ if (kitty.name.len > 0) kitty.name.ptr else null,
+ kitty.pw.len > 0,
+ },
+ else => .{ null, false },
+ };
+
self.app.opts.confirm_read_clipboard(
self.userdata,
- str.ptr,
+ &.{
+ .contents = complete.contents,
+ .contents_len = complete.contents_len,
+ .available = complete.available,
+ .available_len = complete.available_len,
+ .name = name,
+ .can_remember = can_remember,
+ },
state,
state.*,
);
@@ -732,6 +951,14 @@ pub const Surface = struct {
alloc.destroy(state);
}
+ fn denyClipboardRequest(
+ self: *Surface,
+ state: *apprt.ClipboardRequest,
+ ) void {
+ self.core_surface.denyClipboardRequest(state.*);
+ self.app.core_app.alloc.destroy(state);
+ }
+
pub fn setClipboard(
self: *const Surface,
clipboard_type: apprt.Clipboard,
@@ -744,7 +971,8 @@ pub const Surface = struct {
for (contents, 0..) |content, i| {
array[i] = .{
.mime = content.mime,
- .data = content.data,
+ .data = content.data.ptr,
+ .len = content.data.len,
};
}
@@ -950,32 +1178,32 @@ pub const Surface = struct {
};
}
- pub fn defaultTermioEnv(self: *const Surface) !std.process.EnvMap {
- const alloc = self.app.core_app.alloc;
- var env = try internal_os.getEnvMap(alloc);
+ pub fn defaultTermioEnv(self: *const Surface) !std.process.Environ.Map {
+ _ = self;
+ var env = try global.environMap();
errdefer env.deinit();
if (comptime builtin.target.os.tag.isDarwin()) {
if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) {
- env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS");
- env.remove("__XPC_DYLD_LIBRARY_PATH");
- env.remove("DYLD_FRAMEWORK_PATH");
- env.remove("DYLD_INSERT_LIBRARIES");
- env.remove("DYLD_LIBRARY_PATH");
- env.remove("LD_LIBRARY_PATH");
- env.remove("SECURITYSESSIONID");
- env.remove("XPC_SERVICE_NAME");
+ _ = env.orderedRemove("__XCODE_BUILT_PRODUCTS_DIR_PATHS");
+ _ = env.orderedRemove("__XPC_DYLD_LIBRARY_PATH");
+ _ = env.orderedRemove("DYLD_FRAMEWORK_PATH");
+ _ = env.orderedRemove("DYLD_INSERT_LIBRARIES");
+ _ = env.orderedRemove("DYLD_LIBRARY_PATH");
+ _ = env.orderedRemove("LD_LIBRARY_PATH");
+ _ = env.orderedRemove("SECURITYSESSIONID");
+ _ = env.orderedRemove("XPC_SERVICE_NAME");
}
// Remove this so that running `ghostty` within Ghostty works.
- env.remove("GHOSTTY_MAC_LAUNCH_SOURCE");
+ _ = env.orderedRemove("GHOSTTY_MAC_LAUNCH_SOURCE");
// If we were launched from the desktop then we want to
// remove the LANGUAGE env var so that we don't inherit
// our translation settings for Ghostty. If we aren't from
// the desktop then we didn't set our LANGUAGE var so we
// don't need to remove it.
- if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE");
+ if (internal_os.launchedFromDesktop()) _ = env.orderedRemove("LANGUAGE");
}
return env;
@@ -1000,7 +1228,7 @@ pub const Inspector = struct {
content_scale: f64 = 1,
/// Our previous instant used to calculate delta time for animations.
- instant: ?std.time.Instant = null,
+ instant: ?std.Io.Timestamp = null,
const Backend = enum {
metal,
@@ -1229,9 +1457,9 @@ pub const Inspector = struct {
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
// Determine our delta time
- const now = try std.time.Instant.now();
+ const now: std.Io.Timestamp = .now(global.io(), .awake);
io.DeltaTime = if (self.instant) |prev| delta: {
- const since_ns: f64 = @floatFromInt(now.since(prev));
+ const since_ns: f64 = @floatFromInt(prev.durationTo(now).toNanoseconds());
const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s);
const since_s: f32 = @floatCast(since_ns / ns_per_s);
break :delta @max(0.00001, since_s);
@@ -1242,8 +1470,6 @@ pub const Inspector = struct {
// C API
pub const CAPI = struct {
- const global = &@import("../global.zig").state;
-
/// This is the same as Surface.KeyEvent but this is the raw C API version.
const KeyEvent = extern struct {
action: input.Action,
@@ -1284,9 +1510,48 @@ pub const CAPI = struct {
};
// ghostty_clipboard_content_s
+ //
+ // One representation of clipboard contents. The data is binary-safe
+ // and its length is explicit; it is not sentinel-terminated.
const ClipboardContent = extern struct {
mime: [*:0]const u8,
- data: [*:0]const u8,
+ data: [*]const u8,
+ len: usize,
+ };
+
+ // ghostty_clipboard_complete_s
+ //
+ // The payload for completing a clipboard read request. See
+ // Surface.CompleteClipboard for the field documentation.
+ const ClipboardComplete = extern struct {
+ contents: ?[*]const ClipboardContent,
+ contents_len: usize,
+ available: ?[*]const [*:0]const u8,
+ available_len: usize,
+ confirmed: bool,
+ remember: bool,
+ };
+
+ // ghostty_clipboard_confirm_s
+ //
+ // The payload of a clipboard read confirmation request: the
+ // would-be completion contents plus the information shown in the
+ // permission prompt. All memory is borrowed for the duration of
+ // the confirm_read_clipboard callback.
+ const ClipboardConfirm = extern struct {
+ contents: ?[*]const ClipboardContent,
+ contents_len: usize,
+ available: ?[*]const [*:0]const u8,
+ available_len: usize,
+
+ /// The human friendly name of the requesting program for the
+ /// prompt, null when the protocol doesn't carry one.
+ name: ?[*:0]const u8,
+
+ /// True when the user's decision may be remembered as a
+ /// session grant, reported back through the completion's
+ /// remember field.
+ can_remember: bool,
};
// ghostty_text_s
@@ -1300,7 +1565,7 @@ pub const CAPI = struct {
pub fn deinit(self: *Text) void {
if (self.text) |ptr| {
- global.alloc.free(ptr[0..self.text_len :0]);
+ global.alloc().free(ptr[0..self.text_len :0]);
}
}
};
@@ -1410,12 +1675,12 @@ pub const CAPI = struct {
opts: *const apprt.runtime.App.Options,
config: *const Config,
) !*App {
- const core_app = try CoreApp.create(global.alloc);
+ const core_app = try CoreApp.create(global.alloc());
errdefer core_app.destroy();
// Create our runtime app
- var app = try global.alloc.create(App);
- errdefer global.alloc.destroy(app);
+ var app = try global.alloc().create(App);
+ errdefer global.alloc().destroy(app);
try app.init(core_app, config, opts.*);
errdefer app.terminate();
@@ -1438,7 +1703,7 @@ pub const CAPI = struct {
export fn ghostty_app_free(v: *App) void {
const core_app = v.core_app;
v.terminate();
- global.alloc.destroy(v);
+ global.alloc().destroy(v);
core_app.destroy();
}
@@ -1490,7 +1755,7 @@ pub const CAPI = struct {
/// Open the configuration.
export fn ghostty_app_open_config(v: *App) void {
- _ = v.performAction(.app, .open_config, {}) catch |err| {
+ _ = v.performAction(.app, .open_config, .new_window) catch |err| {
log.err("error reloading config err={}", .{err});
return;
};
@@ -1520,13 +1785,7 @@ pub const CAPI = struct {
/// Update the color scheme of the app.
export fn ghostty_app_set_color_scheme(v: *App, scheme_raw: c_int) void {
- const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch {
- log.warn(
- "invalid color scheme to ghostty_surface_set_color_scheme value={}",
- .{scheme_raw},
- );
- return;
- };
+ const scheme = std.enums.fromInt(apprt.ColorScheme, scheme_raw) orelse return;
v.core_app.colorSchemeEvent(v, scheme) catch |err| {
log.err("error setting color scheme err={}", .{err});
@@ -1612,8 +1871,8 @@ pub const CAPI = struct {
result: *Text,
) bool {
const core_surface = &surface.core_surface;
- core_surface.renderer_state.mutex.lock();
- defer core_surface.renderer_state.mutex.unlock();
+ core_surface.renderer_state.mutex.lockUncancelable(global.io());
+ defer core_surface.renderer_state.mutex.unlock(global.io());
// If we don't have a selection, do nothing.
const core_sel = core_surface.io.terminal.screens.active.selection orelse return false;
@@ -1632,8 +1891,8 @@ pub const CAPI = struct {
sel: Selection,
result: *Text,
) bool {
- surface.core_surface.renderer_state.mutex.lock();
- defer surface.core_surface.renderer_state.mutex.unlock();
+ surface.core_surface.renderer_state.mutex.lockUncancelable(global.io());
+ defer surface.core_surface.renderer_state.mutex.unlock(global.io());
const core_sel = sel.core(
surface.core_surface.renderer_state.terminal.screens.active,
@@ -1651,7 +1910,7 @@ pub const CAPI = struct {
// Get our text directly from the core surface.
const text = core_surface.dumpTextLocked(
- global.alloc,
+ global.alloc(),
core_sel,
) catch |err| {
log.warn("error reading text err={}", .{err});
@@ -1730,14 +1989,7 @@ pub const CAPI = struct {
/// Update the color scheme of the surface.
export fn ghostty_surface_set_color_scheme(surface: *Surface, scheme_raw: c_int) void {
- const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch {
- log.warn(
- "invalid color scheme to ghostty_surface_set_color_scheme value={}",
- .{scheme_raw},
- );
- return;
- };
-
+ const scheme = std.enums.fromInt(apprt.ColorScheme, scheme_raw) orelse return;
surface.colorSchemeCallback(scheme);
}
@@ -1891,17 +2143,7 @@ pub const CAPI = struct {
stage_raw: u32,
pressure: f64,
) void {
- const stage = std.meta.intToEnum(
- input.MousePressureStage,
- stage_raw,
- ) catch {
- log.warn(
- "invalid mouse pressure stage value={}",
- .{stage_raw},
- );
- return;
- };
-
+ const stage = std.enums.fromInt(input.MousePressureStage, stage_raw) orelse return;
surface.mousePressureCallback(stage, pressure);
}
@@ -2001,20 +2243,32 @@ pub const CAPI = struct {
};
}
- /// Complete a clipboard read request started via the read callback.
- /// This can only be called once for a given request. Once it is called
- /// with a request the request pointer will be invalidated.
+ /// Complete a clipboard read request started via the read callback
+ /// with the representations that could be served and, if requested,
+ /// the listing of available MIME types. All memory is borrowed for
+ /// the duration of the call. This can only be called once for a given
+ /// request. Once it is called with a request the request pointer will
+ /// be invalidated.
+ ///
+ /// To deny a request use ghostty_surface_deny_clipboard_request
+ /// instead.
export fn ghostty_surface_complete_clipboard_request(
ptr: *Surface,
- str: [*:0]const u8,
+ complete: *const ClipboardComplete,
state: *apprt.ClipboardRequest,
- confirmed: bool,
) void {
- ptr.completeClipboardRequest(
- std.mem.sliceTo(str, 0),
- state,
- confirmed,
- );
+ ptr.completeClipboardRequest(complete, state);
+ }
+
+ /// Deny a clipboard read request started via the read callback,
+ /// e.g. because the user rejected a confirmation prompt. Request
+ /// types whose protocol expects an answer have their denial reply
+ /// written to the pty. The request pointer is invalidated.
+ export fn ghostty_surface_deny_clipboard_request(
+ ptr: *Surface,
+ state: *apprt.ClipboardRequest,
+ ) void {
+ ptr.denyClipboardRequest(state);
}
export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector {
@@ -2134,6 +2388,7 @@ pub const CAPI = struct {
export fn ghostty_surface_set_display_id(ptr: *Surface, display_id: u32) void {
const surface = &ptr.core_surface;
_ = surface.renderer_thread.mailbox.push(
+ global.io(),
.{ .macos_display_id = display_id },
.{ .forever = {} },
);
@@ -2157,8 +2412,8 @@ pub const CAPI = struct {
// read the font face. It should not be deferred since
// we're loading the primary face.
const grid = ptr.core_surface.renderer.font_grid;
- grid.lock.lockShared();
- defer grid.lock.unlockShared();
+ grid.lock.lockSharedUncancelable(global.io());
+ defer grid.lock.unlockShared(global.io());
const collection = &grid.resolver.collection;
const face = collection.getFace(.{}) catch return null;
@@ -2195,8 +2450,8 @@ pub const CAPI = struct {
result: *Text,
) bool {
const surface = &ptr.core_surface;
- surface.renderer_state.mutex.lock();
- defer surface.renderer_state.mutex.unlock();
+ surface.renderer_state.mutex.lockUncancelable(global.io());
+ defer surface.renderer_state.mutex.unlock(global.io());
// Get our word selection
const sel = sel: {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/App.zig b/vendor/libghostty-vt/src/apprt/gtk/App.zig
index 8a7b3a8e..9fbea7c1 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/App.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/App.zig
@@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("Surface.zig");
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
+const ipcNewTab = @import("ipc/new_tab.zig").newTab;
const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal;
const log = std.log.scoped(.gtk);
@@ -85,6 +86,7 @@ pub fn performIpc(
) !bool {
switch (action) {
.new_window => return try ipcNewWindow(alloc, target, value),
+ .new_tab => return try ipcNewTab(alloc, target, value),
.toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target),
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig
index 71597367..4a59ab21 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig
@@ -74,7 +74,7 @@ pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
-) !bool {
+) !apprt.ClipboardReadResult {
return try self.surface.clipboardRequest(
clipboard_type,
state,
@@ -94,7 +94,7 @@ pub fn setClipboard(
);
}
-pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
+pub fn defaultTermioEnv(self: *Self) !std.process.Environ.Map {
return try self.surface.defaultTermioEnv();
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig
index 6f7be52d..1ac750fb 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig
@@ -4,18 +4,16 @@ const std = @import("std");
// Ghostty, we need to import `adwaita.h` directly to ensure that the version
// macros match the version of `libadwaita` that we are building/linking
// against.
-const c = @cImport({
- @cInclude("adwaita.h");
-});
+const adw_c = @import("adw_c");
const adw = @import("adw");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
- .major = c.ADW_MAJOR_VERSION,
- .minor = c.ADW_MINOR_VERSION,
- .patch = c.ADW_MICRO_VERSION,
+ .major = adw_c.ADW_MAJOR_VERSION,
+ .minor = adw_c.ADW_MINOR_VERSION,
+ .patch = adw_c.ADW_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
@@ -89,14 +87,14 @@ test "versionAtLeast" {
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
- try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
- try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
- try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
- try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
- try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION));
- try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION));
- try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1));
- try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1));
+ try testing.expect(fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION));
+ try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION + 1));
+ try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION + 1, adw_c.ADW_MICRO_VERSION));
+ try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION + 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION));
+ try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION));
+ try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION + 1, adw_c.ADW_MICRO_VERSION));
+ try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION + 1));
+ try testing.expect(fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION - 1, adw_c.ADW_MICRO_VERSION + 1));
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig
index 4920ce6f..fb3f6ae2 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig
@@ -6,10 +6,7 @@
//! Example: blueprint.zig 1 5 output.ui input.blp
const std = @import("std");
-
-pub const c = @cImport({
- @cInclude("adwaita.h");
-});
+const adw_c = @import("adw_c");
pub const blueprint_compiler_help =
\\
@@ -26,23 +23,24 @@ pub const blueprint_compiler_help =
;
const adwaita_version = std.SemanticVersion{
- .major = c.ADW_MAJOR_VERSION,
- .minor = c.ADW_MINOR_VERSION,
- .patch = c.ADW_MICRO_VERSION,
+ .major = adw_c.ADW_MAJOR_VERSION,
+ .minor = adw_c.ADW_MINOR_VERSION,
+ .patch = adw_c.ADW_MICRO_VERSION,
};
+
const required_blueprint_version = std.SemanticVersion{
.major = 0,
.minor = 16,
.patch = 0,
};
-pub fn main() !void {
+pub fn main(init: std.process.Init) !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
// Get our args
- var it = try std.process.argsWithAllocator(alloc);
+ var it = try init.minimal.args.iterateAllocator(alloc);
defer it.deinit();
_ = it.next(); // Skip argv0
const arg_major = it.next() orelse return error.NoMajorVersion;
@@ -63,51 +61,37 @@ pub fn main() !void {
\\compile this blueprint. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
, .{required_adwaita_version});
- std.posix.exit(1);
+ std.process.exit(1);
}
// Version checks
{
- var stdout: std.ArrayListUnmanaged(u8) = .empty;
- defer stdout.deinit(alloc);
- var stderr: std.ArrayListUnmanaged(u8) = .empty;
- defer stderr.deinit(alloc);
-
- var blueprint_compiler = std.process.Child.init(
- &.{
- "blueprint-compiler",
- "--version",
- },
- alloc,
- );
- blueprint_compiler.stdout_behavior = .Pipe;
- blueprint_compiler.stderr_behavior = .Pipe;
- try blueprint_compiler.spawn();
- try blueprint_compiler.collectOutput(
- alloc,
- &stdout,
- &stderr,
- std.math.maxInt(u16),
- );
- const term = blueprint_compiler.wait() catch |err| switch (err) {
+ const blueprint_compiler = std.process.run(alloc, init.io, .{
+ .argv = &.{ "blueprint-compiler", "--version" },
+ }) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
++ blueprint_compiler_help,
.{required_blueprint_version},
);
- std.posix.exit(1);
+ std.process.exit(1);
},
else => return err,
};
- switch (term) {
- .Exited => |rc| if (rc != 0) std.process.exit(1),
+ defer {
+ alloc.free(blueprint_compiler.stdout);
+ alloc.free(blueprint_compiler.stderr);
+ }
+
+ switch (blueprint_compiler.term) {
+ .exited => |rc| if (rc != 0) std.process.exit(1),
else => std.process.exit(1),
}
const version = try std.SemanticVersion.parse(std.mem.trim(
u8,
- stdout.items,
+ blueprint_compiler.stdout,
&std.ascii.whitespace,
));
if (version.order(required_blueprint_version) == .lt) {
@@ -116,57 +100,45 @@ pub fn main() !void {
++ blueprint_compiler_help,
.{required_blueprint_version},
);
- std.posix.exit(1);
+ std.process.exit(1);
}
}
// Compilation
{
- var stdout: std.ArrayListUnmanaged(u8) = .empty;
- defer stdout.deinit(alloc);
- var stderr: std.ArrayListUnmanaged(u8) = .empty;
- defer stderr.deinit(alloc);
-
- var blueprint_compiler = std.process.Child.init(
- &.{
+ const blueprint_compiler = std.process.run(alloc, init.io, .{
+ .argv = &.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
- alloc,
- );
- blueprint_compiler.stdout_behavior = .Pipe;
- blueprint_compiler.stderr_behavior = .Pipe;
- try blueprint_compiler.spawn();
- try blueprint_compiler.collectOutput(
- alloc,
- &stdout,
- &stderr,
- std.math.maxInt(u16),
- );
- const term = blueprint_compiler.wait() catch |err| switch (err) {
+ }) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
++ blueprint_compiler_help,
.{required_blueprint_version},
);
- std.posix.exit(1);
+ std.process.exit(1);
},
else => return err,
};
+ defer {
+ alloc.free(blueprint_compiler.stdout);
+ alloc.free(blueprint_compiler.stderr);
+ }
- switch (term) {
- .Exited => |rc| {
+ switch (blueprint_compiler.term) {
+ .exited => |rc| {
if (rc != 0) {
- std.debug.print("{s}", .{stderr.items});
+ std.debug.print("{s}", .{blueprint_compiler.stderr});
std.process.exit(1);
}
},
else => {
- std.debug.print("{s}", .{stderr.items});
+ std.debug.print("{s}", .{blueprint_compiler.stderr});
std.process.exit(1);
},
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig
index c50ea8cd..57c32a35 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig
@@ -122,18 +122,17 @@ pub fn blueprint(comptime bp: Blueprint) [:0]const u8 {
}
}
-pub fn main() !void {
- var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
- defer _ = debug_allocator.deinit();
- const alloc = debug_allocator.allocator();
+pub fn main(init: std.process.Init) !void {
+ const alloc = init.arena.allocator();
// Collect the UI files that are passed in as arguments.
- var ui_files: std.ArrayListUnmanaged([]const u8) = .empty;
+ var ui_files: std.ArrayList([]const u8) = .empty;
defer {
for (ui_files.items) |item| alloc.free(item);
ui_files.deinit(alloc);
}
- var it = try std.process.argsWithAllocator(alloc);
+
+ var it = try init.minimal.args.iterateAllocator(alloc);
defer it.deinit();
while (it.next()) |arg| {
if (!std.mem.endsWith(u8, arg, ".ui")) continue;
@@ -144,7 +143,7 @@ pub fn main() !void {
}
var buf: [4096]u8 = undefined;
- var stdout = std.fs.File.stdout().writer(&buf);
+ var stdout = std.Io.File.stdout().writer(init.io, &buf);
const writer = &stdout.interface;
try writer.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
@@ -152,8 +151,8 @@ pub fn main() !void {
\\
);
- try genRoot(writer);
- try genIcons(writer);
+ try genRoot(init.io, writer);
+ try genIcons(init.io, writer);
try genUi(alloc, writer, &ui_files);
try writer.writeAll(
@@ -167,19 +166,19 @@ pub fn main() !void {
/// Generate the icon resources. This works by looking up all the icons
/// specified by `icon_sizes` in `images/icons/`. They are asserted to exist
/// by trying to access the file.
-fn genIcons(writer: *std.Io.Writer) !void {
+fn genIcons(io: std.Io, writer: *std.Io.Writer) !void {
try writer.print(
\\ <gresource prefix="{s}/icons">
\\
, .{build_info.resource_path});
- const cwd = std.fs.cwd();
+ const cwd: std.Io.Dir = .cwd();
inline for (icon_sizes) |size| {
// 1x
{
const alias = std.fmt.comptimePrint("{d}x{d}", .{ size, size });
const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size});
- try cwd.access(source, .{});
+ try cwd.access(io, source, .{});
try writer.print(
\\ <file alias="{s}/apps/{s}.png">{s}</file>
\\
@@ -192,7 +191,7 @@ fn genIcons(writer: *std.Io.Writer) !void {
{
const alias = std.fmt.comptimePrint("{d}x{d}@2", .{ size, size });
const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size * 2});
- try cwd.access(source, .{});
+ try cwd.access(io, source, .{});
try writer.print(
\\ <file alias="{s}/apps/{s}.png">{s}</file>
\\
@@ -209,19 +208,19 @@ fn genIcons(writer: *std.Io.Writer) !void {
}
/// Generate the resources at the root prefix.
-fn genRoot(writer: *std.Io.Writer) !void {
+fn genRoot(io: std.Io, writer: *std.Io.Writer) !void {
try writer.print(
\\ <gresource prefix="{s}">
\\
, .{build_info.resource_path});
- const cwd = std.fs.cwd();
+ const cwd: std.Io.Dir = .cwd();
inline for (css) |name| {
const source = std.fmt.comptimePrint(
"{s}/{s}",
.{ css_path, name },
);
- try cwd.access(source, .{});
+ try cwd.access(io, source, .{});
try writer.print(
\\ <file compressed="true" alias="{s}">{s}</file>
\\
@@ -242,7 +241,7 @@ fn genRoot(writer: *std.Io.Writer) !void {
fn genUi(
alloc: Allocator,
writer: *std.Io.Writer,
- files: *const std.ArrayListUnmanaged([]const u8),
+ files: *const std.ArrayList([]const u8),
) !void {
try writer.print(
\\ <gresource prefix="{s}/ui">
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class.zig b/vendor/libghostty-vt/src/apprt/gtk/class.zig
index 942666cf..a1dba716 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class.zig
@@ -145,16 +145,24 @@ pub fn Common(
/// as the virtual method but the self parameter points to the
/// target instead of the original class.
fn ImplementFunc(comptime T: type) type {
- var params: [fn_info.params.len]std.builtin.Type.Fn.Param = undefined;
- @memcpy(&params, fn_info.params);
- params[0].type = *ClassInstance(T);
- return @Type(.{ .@"fn" = .{
- .calling_convention = fn_info.calling_convention,
- .is_generic = fn_info.is_generic,
- .is_var_args = fn_info.is_var_args,
- .return_type = fn_info.return_type,
- .params = &params,
- } });
+ var types: [fn_info.params.len]type = undefined;
+ var attrs: [fn_info.params.len]std.builtin.Type.Fn.Param.Attributes = undefined;
+
+ for (fn_info.params, &types, &attrs) |info, *ty, *attr| {
+ ty.* = info.type.?;
+ attr.* = .{ .@"noalias" = info.is_noalias };
+ }
+ types[0] = *ClassInstance(T);
+
+ return @Fn(
+ &types,
+ &attrs,
+ fn_info.return_type.?,
+ .{
+ .@"callconv" = fn_info.calling_convention,
+ .varargs = fn_info.is_var_args,
+ },
+ );
}
};
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
index a627086e..5b9d3bf2 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig
@@ -1,3 +1,4 @@
+const builtin = @import("builtin");
const std = @import("std");
const assert = @import("../../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
@@ -5,25 +6,27 @@ const adw = @import("adw");
const gdk = @import("gdk");
const gio = @import("gio");
const glib = @import("glib");
+const glibunix = @import("glibunix");
const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const build_info = @import("../build/info.zig");
-const state = &@import("../../../global.zig").state;
+const cli = @import("../../../cli.zig");
+const global = @import("../../../global.zig");
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const CoreApp = @import("../../../App.zig");
+const compat_file = @import("../../../lib/compat/file.zig");
const configpkg = @import("../../../config.zig");
const input = @import("../../../input.zig");
const internal_os = @import("../../../os/main.zig");
const systemd = @import("../../../os/systemd.zig");
const terminal = @import("../../../terminal/main.zig");
-const xev = @import("../../../global.zig").xev;
+const xev = global.xev;
const Binding = @import("../../../input.zig").Binding;
const CoreConfig = configpkg.Config;
const CoreSurface = @import("../../../Surface.zig");
-const lib = @import("../../../lib/main.zig");
const ext = @import("../ext.zig");
const key = @import("../key.zig");
@@ -42,9 +45,12 @@ const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseCo
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts;
const OpenURI = @import("../portal.zig").OpenURI;
+const media = @import("../media.zig");
const log = std.log.scoped(.gtk_ghostty_application);
+extern "c" fn setenv(name: ?[*]const u8, value: ?[*]const u8, overwrite: c_int) c_int;
+
/// Function used to funnel GLib/GObject/GTK log messages into Zig's logging
/// system rather than just getting dumped directly to stderr.
fn glibLogWriterFunction(
@@ -218,6 +224,12 @@ pub const Application = extern struct {
open_uri: OpenURI = undefined,
+ // The audio bell's MediaFile, reused across bells so we don't leak a
+ // GStreamer pipeline (and its GL threads) on every ring. Built lazily
+ // on the first audio bell and rebuilt when `bell-audio-path` changes;
+ // unref'd on dispose. See ringBell and media.zig.
+ bell_media: ?*gtk.MediaFile = null,
+
pub var offset: c_int = 0;
};
@@ -271,15 +283,17 @@ pub const Application = extern struct {
};
defer config.deinit();
+ // Set the old language,
const saved_language: ?[:0]const u8 = saved_language: {
const old_language = old_language: {
- const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null;
- defer result.deinit(alloc);
- break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null;
+ const lang = global.environ().getPosix("LANG") orelse break :old_language null;
+ break :old_language alloc.dupeSentinel(u8, @ptrCast(lang), 0) catch null;
};
-
- if (config.language) |language| _ = internal_os.setenv("LANG", language);
-
+ if (config.language) |language| {
+ // Override LANG if we need to (sync global environs if so)
+ _ = setenv("LANG", @ptrCast(language), 1);
+ global.syncEnviron();
+ }
break :saved_language old_language;
};
@@ -294,7 +308,7 @@ pub const Application = extern struct {
// Setup our GTK init env vars
setGtkEnv(&config) catch |err| switch (err) {
- error.NoSpaceLeft => {
+ error.WriteFailed => {
// If we fail to set GTK environment variables then we still
// try to start the application...
log.warn(
@@ -338,7 +352,7 @@ pub const Application = extern struct {
// I'm unsure of any scenario where this happens. Because we don't
// want to litter null checks everywhere, we just exit here.
log.warn("gdk display is null, exiting", .{});
- std.posix.exit(1);
+ std.process.exit(1);
};
// Setup our windowing protocol logic
@@ -696,6 +710,11 @@ pub const Application = extern struct {
.initial_size => return Action.initialSize(target, value),
.inspector => return Action.controlInspector(target, value),
+ .export_terminal_io => return try Action.exportTerminalIO(
+ self,
+ target,
+ value,
+ ),
.key_sequence => return Action.keySequence(target, value),
.key_table => return Action.keyTable(target, value),
@@ -705,10 +724,11 @@ pub const Application = extern struct {
.mouse_visibility => Action.mouseVisibility(target, value),
.move_tab => return Action.moveTab(target, value),
+ .move_tab_to_new_window => return Action.moveTabToNewWindow(target),
.new_split => return Action.newSplit(target, value),
- .new_tab => return Action.newTab(target),
+ .new_tab => return Action.newTab(target, .none),
.new_window => try Action.newWindow(
self,
@@ -719,7 +739,7 @@ pub const Application = extern struct {
.none,
),
- .open_config => return Action.openConfig(self),
+ .open_config => return Action.openConfig(self, value),
.open_url => Action.openUrl(self, value),
@@ -750,6 +770,7 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value),
.set_tab_title => return Action.setTabTitle(target, value),
+ .set_window_title => return Action.setWindowTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
@@ -1016,6 +1037,38 @@ pub const Application = extern struct {
\\}
\\
\\/*
+ \\ * Drag and Drop Overlay
+ \\ */
+ \\.drop-overlay.drop-left {
+ \\ background: linear-gradient(
+ \\ to left,
+ \\ transparent, 50%,
+ \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50%
+ \\ );
+ \\}
+ \\.drop-overlay.drop-right {
+ \\ background: linear-gradient(
+ \\ to right,
+ \\ transparent, 50%,
+ \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50%
+ \\ );
+ \\}
+ \\.drop-overlay.drop-top {
+ \\ background: linear-gradient(
+ \\ to top,
+ \\ transparent, 50%,
+ \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50%
+ \\ );
+ \\}
+ \\.drop-overlay.drop-bottom {
+ \\ background: linear-gradient(
+ \\ to bottom,
+ \\ transparent, 50%,
+ \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50%
+ \\ );
+ \\}
+ \\
+ \\/*
\\ * Splits
\\ */
\\
@@ -1063,7 +1116,11 @@ pub const Application = extern struct {
}
}
- fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void {
+ const LoadCustomCssError = std.Io.File.OpenError ||
+ compat_file.ReadToEndAllocError ||
+ std.mem.Allocator.Error;
+
+ fn loadCustomCss(self: *Self) LoadCustomCssError!void {
const priv: *Private = self.private();
const alloc = self.allocator();
const display = gdk.Display.getDefault() orelse {
@@ -1087,7 +1144,11 @@ pub const Application = extern struct {
.optional => |path| .{ path, true },
.required => |path| .{ path, false },
};
- const file = std.fs.openFileAbsolute(path, .{}) catch |err| {
+ const file = std.Io.Dir.openFileAbsolute(
+ global.io(),
+ path,
+ .{},
+ ) catch |err| {
if (err != error.FileNotFound or !optional) {
log.warn(
"error opening gtk-custom-css file {s}: {}",
@@ -1096,12 +1157,14 @@ pub const Application = extern struct {
}
continue;
};
- defer file.close();
+ defer file.close(global.io());
const css_file_size_limit = 5 * 1024 * 1024; // 5MB
log.info("loading gtk-custom-css path={s}", .{path});
- const contents = file.readToEndAlloc(
+
+ const contents = compat_file.readToEndAlloc(
+ file,
alloc,
css_file_size_limit,
) catch |err| switch (err) {
@@ -1111,6 +1174,7 @@ pub const Application = extern struct {
},
else => |e| return e,
};
+
defer alloc.free(contents);
const bytes = glib.Bytes.new(contents.ptr, contents.len);
@@ -1141,7 +1205,8 @@ pub const Application = extern struct {
fn syncActionAccelerators(self: *Self) void {
self.syncActionAccelerator("app.quit", .{ .quit = {} });
- self.syncActionAccelerator("app.open-config", .{ .open_config = {} });
+ self.syncActionAccelerator("app.open-config::os-open", .{ .open_config = .os_open });
+ self.syncActionAccelerator("app.open-config::new-window", .{ .open_config = .new_window });
self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} });
self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle });
self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector);
@@ -1400,8 +1465,8 @@ pub const Application = extern struct {
fn startupSignals(self: *Self) void {
const priv = self.private();
assert(priv.signal_source == null);
- priv.signal_source = glib.unixSignalAdd(
- std.posix.SIG.USR2,
+ priv.signal_source = glibunix.signalAdd(
+ @intFromEnum(std.posix.SIG.USR2),
handleSigusr2,
self,
);
@@ -1415,14 +1480,22 @@ pub const Application = extern struct {
const as_variant_type = glib.VariantType.new("as");
defer as_variant_type.free();
+ const tas_variant_type = glib.VariantType.new("(tas)");
+ defer tas_variant_type.free();
+
+ const s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
+ defer s_variant_type.free();
+
const actions = [_]ext.actions.Action(Self){
.init("new-window", actionNewWindow, null),
.init("new-window-command", actionNewWindow, as_variant_type),
- .init("open-config", actionOpenConfig, null),
+ .init("new-tab", actionNewTab, tas_variant_type),
+ .init("open-config", actionOpenConfig, s_variant_type),
.init("present-surface", actionPresentSurface, t_variant_type),
.init("quit", actionQuit, null),
.init("reload-config", actionReloadConfig, null),
.init("toggle-quick-terminal", actionToggleQuickTerminal, null),
+ .init("ring-bell", actionRingBell, null),
};
ext.actions.add(Self, self, &actions);
@@ -1454,6 +1527,14 @@ pub const Application = extern struct {
self,
.{},
);
+
+ _ = GlobalShortcuts.signals.@"bind-failed".connect(
+ priv.global_shortcuts,
+ *Application,
+ globalShortcutBindFailed,
+ self,
+ .{},
+ );
}
fn activate(self: *Self) callconv(.c) void {
@@ -1461,7 +1542,7 @@ pub const Application = extern struct {
// Queue a new window
const priv = self.private();
- _ = priv.core_app.mailbox.push(.{
+ _ = priv.core_app.mailbox.push(global.io(), .{
.new_window = .{},
}, .{ .forever = {} });
@@ -1478,7 +1559,7 @@ pub const Application = extern struct {
diag.close();
diag.unref(); // strong ref from get()
}
- priv.config_errors_dialog.set(null);
+ priv.config_errors_dialog.deinit();
if (priv.signal_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove signal source", .{});
@@ -1486,6 +1567,11 @@ pub const Application = extern struct {
priv.signal_source = null;
}
+ if (priv.bell_media) |v| {
+ v.unref();
+ priv.bell_media = null;
+ }
+
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
@@ -1662,6 +1748,41 @@ pub const Application = extern struct {
};
}
+ /// May fire before any window exists, hence a desktop notification
+ /// rather than a toast.
+ fn globalShortcutBindFailed(
+ _: *GlobalShortcuts,
+ failure: *const GlobalShortcuts.BindFailed,
+ self: *Self,
+ ) callconv(.c) void {
+ var label_buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&label_buf);
+ const label: []const u8 = label: {
+ const ok = key.labelFromTrigger(&writer, failure.trigger) catch false;
+ break :label if (ok) writer.buffered() else "?";
+ };
+
+ const detail: [*:0]const u8 = detail: {
+ if (failure.message[0] != 0) break :detail failure.message;
+ break :detail if (failure.revoked)
+ i18n._("The keybind was revoked by the system.")
+ else
+ i18n._("The keybind was denied by the system.");
+ };
+
+ var body_buf: [512]u8 = undefined;
+ const body = std.fmt.bufPrintZ(
+ &body_buf,
+ "{s}: {s}",
+ .{ label, detail },
+ ) catch return;
+
+ Action.desktopNotification(self, .app, .{
+ .title = std.mem.span(i18n._("Global keybind unavailable")),
+ .body = body,
+ });
+ }
+
fn actionReloadConfig(
_: *gio.SimpleAction,
_: ?*glib.Variant,
@@ -1703,93 +1824,227 @@ pub const Application = extern struct {
) callconv(.c) void {
log.debug("received new window action", .{});
- var arena: std.heap.ArenaAllocator = .init(Application.default().allocator());
+ var arena: std.heap.ArenaAllocator = .init(self.core().alloc);
defer arena.deinit();
const alloc = arena.allocator();
- var working_directory: ?[:0]const u8 = null;
- var title: ?[:0]const u8 = null;
- var command: ?configpkg.Command = null;
- var args: std.ArrayList([:0]const u8) = .empty;
-
- overrides: {
+ const overrides = overrides: {
// were we given a parameter?
- const parameter = parameter_ orelse break :overrides;
+ const arguments = parameter_ orelse break :overrides null;
const as_variant_type = glib.VariantType.new("as");
defer as_variant_type.free();
// ensure that the supplied parameter is an array of strings
- if (glib.Variant.isOfType(parameter, as_variant_type) == 0) {
+ if (glib.Variant.isOfType(arguments, as_variant_type) == 0) {
log.warn("parameter is of type '{s}', not '{s}'", .{
- parameter.getTypeString(),
+ arguments.getTypeString(),
as_variant_type.peekString()[0..as_variant_type.getStringLength()],
});
- break :overrides;
+ break :overrides null;
}
- const s_variant_type = glib.VariantType.new("s");
- defer s_variant_type.free();
+ var arguments_it: glib.VariantIter = undefined;
+ _ = arguments_it.init(arguments);
- var it: glib.VariantIter = undefined;
- _ = it.init(parameter);
+ break :overrides parseOverrides(alloc, &arguments_it) catch null;
+ };
- var e_seen: bool = false;
- var i: usize = 0;
+ Action.newWindow(
+ self,
+ null,
+ if (overrides) |o| .{
+ .command = o.command,
+ .shell_integration = o.shell_integration,
+ .working_directory = o.working_directory,
+ .title = o.title,
+ } else .none,
+ ) catch |err| {
+ log.warn("unable to create new window: {t}", .{err});
+ };
+ }
- while (it.nextValue()) |value| : (i += 1) {
- defer value.unref();
+ /// Handle `app.new-tab` GTK action
+ pub fn actionNewTab(
+ _: *gio.SimpleAction,
+ parameter_: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ log.debug("received new tab action", .{});
- // just to be sure
- if (value.isOfType(s_variant_type) == 0) continue;
+ const core_app = self.core();
- var len: usize = undefined;
- const buf = value.getString(&len);
- const str = buf[0..len];
+ var arena: std.heap.ArenaAllocator = .init(core_app.alloc);
+ defer arena.deinit();
- log.debug("new-window argument: {d} {s}", .{ i, str });
+ const alloc = arena.allocator();
- if (e_seen) {
- const duplicated = alloc.dupeZ(u8, str) catch |err| {
- log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err });
- break :overrides;
- };
- args.append(alloc, duplicated) catch |err| {
- log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err });
- break :overrides;
- };
- continue;
- }
+ const surface_id_, const overrides = result: {
+ // were we given a parameter?
+ const parameter = parameter_ orelse {
+ log.warn("app.new-tab did not receive a parameter", .{});
+ return;
+ };
- if (std.mem.eql(u8, str, "-e")) {
- e_seen = true;
- continue;
- }
+ log.warn("got parameter: {s}", .{parameter.getTypeString()});
- if (lib.cutPrefix(u8, str, "--command=")) |v| {
- var cmd: configpkg.Command = undefined;
- cmd.parseCLI(alloc, v) catch |err| {
- log.warn("unable to parse command: {t}", .{err});
- continue;
- };
- command = cmd;
- continue;
- }
- if (lib.cutPrefix(u8, str, "--working-directory=")) |v| {
- working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: {
- log.warn("unable to duplicate working directory: {t}", .{err});
- break :wd null;
- };
- continue;
- }
- if (lib.cutPrefix(u8, str, "--title=")) |v| {
- title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: {
- log.warn("unable to duplicate title: {t}", .{err});
- break :t null;
- };
+ const tas_variant_type = glib.VariantType.new("(tas)");
+ defer tas_variant_type.free();
+
+ // ensure that the supplied parameter is an array of strings
+ if (glib.Variant.isOfType(parameter, tas_variant_type) == 0) {
+ log.warn("parameter is of type '{s}', not '{s}'", .{
+ parameter.getTypeString(),
+ tas_variant_type.peekString()[0..tas_variant_type.getStringLength()],
+ });
+ return;
+ }
+
+ var surface_id: u64 = 0;
+ var arguments_it_: ?*glib.VariantIter = null;
+ defer if (arguments_it_) |arguments_it| arguments_it.free();
+
+ parameter.get("(tas)", &surface_id, &arguments_it_);
+
+ const arguments_it = arguments_it_ orelse return;
+
+ const overrides = parseOverrides(alloc, arguments_it) catch return;
+
+ break :result .{ if (surface_id == 0) null else surface_id, overrides };
+ };
+
+ const surface_ = surface: {
+ if (surface_id_) |surface_id| find_by_id: {
+ break :surface core_app.findSurfaceByID(surface_id) orelse {
+ log.warn("new-tab: unable to find surface 0x{x:0>16}", .{surface_id});
+ break :find_by_id;
+ };
+ }
+ find_focused: {
+ break :surface core_app.focusedSurface() orelse {
+ log.warn("new-tab: unable to find surface", .{});
+ break :find_focused;
+ };
+ }
+ break :surface null;
+ };
+
+ if (surface_) |surface| {
+ if (!Action.newTab(
+ .{
+ .surface = surface,
+ },
+ .{
+ .command = overrides.command,
+ .shell_integration = overrides.shell_integration,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ },
+ )) {
+ log.warn("new-tab: unable to create tab", .{});
+ }
+ } else {
+ Action.newWindow(
+ self,
+ null,
+ .{
+ .command = overrides.command,
+ .shell_integration = overrides.shell_integration,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ },
+ ) catch |err| {
+ log.warn("new-tab: unable to create new window: {t}", .{err});
+ };
+ }
+ }
+
+ fn parseOverrides(arena_alloc: Allocator, arguments_it: *glib.VariantIter) (Allocator.Error || error{ValueRequired})!struct {
+ command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+ } {
+ var args: std.ArrayList([:0]const u8) = .empty;
+
+ var working_directory: ?[:0]const u8 = null;
+ var title: ?[:0]const u8 = null;
+ var command: ?configpkg.Command = null;
+ var parsed_shell_integration: struct {
+ @"shell-integration": ?configpkg.Config.ShellIntegration = null,
+ } = .{};
+
+ const s_variant_type = glib.VariantType.new("s");
+ defer s_variant_type.free();
+
+ var e_seen: bool = false;
+ var i: usize = 0;
+
+ while (arguments_it.nextValue()) |value| : (i += 1) {
+ defer value.unref();
+
+ // just to be sure
+ if (value.isOfType(s_variant_type) == 0) continue;
+
+ var len: usize = undefined;
+ const buf = value.getString(&len);
+ const str = buf[0..len];
+
+ log.debug("argument: {d} {s}", .{ i, str });
+
+ if (e_seen) {
+ const copy = arena_alloc.dupeZ(u8, str) catch |err| {
+ log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err });
+ return err;
+ };
+ args.append(arena_alloc, copy) catch |err| {
+ log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err });
+ return err;
+ };
+ continue;
+ }
+
+ if (std.mem.eql(u8, str, "-e")) {
+ e_seen = true;
+ continue;
+ }
+
+ if (std.mem.cutPrefix(u8, str, "--command=")) |v| {
+ var cmd: configpkg.Command = undefined;
+ cmd.parseCLI(arena_alloc, v) catch |err| {
+ log.warn("unable to parse command: {t}", .{err});
+ return err;
+ };
+ command = cmd;
+ continue;
+ }
+ if (std.mem.cutPrefix(u8, str, "--shell-integration=")) |v| {
+ cli.args.parseIntoField(
+ @TypeOf(parsed_shell_integration),
+ arena_alloc,
+ &parsed_shell_integration,
+ "shell-integration",
+ std.mem.trim(u8, v, &std.ascii.whitespace),
+ ) catch |err| {
+ log.warn("unable to parse shell integration {s}: {t}", .{ v, err });
continue;
- }
+ };
+ continue;
+ }
+ if (std.mem.cutPrefix(u8, str, "--working-directory=")) |v| {
+ working_directory = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| {
+ log.warn("unable to duplicate working directory: {t}", .{err});
+ return err;
+ };
+ continue;
+ }
+ if (std.mem.cutPrefix(u8, str, "--title=")) |v| {
+ title = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| {
+ log.warn("unable to duplicate title: {t}", .{err});
+ return err;
+ };
+ continue;
}
}
@@ -1799,21 +2054,52 @@ pub const Application = extern struct {
};
}
- Action.newWindow(self, null, .{
+ return .{
.command = command,
+ .shell_integration = parsed_shell_integration.@"shell-integration",
.working_directory = working_directory,
.title = title,
- }) catch |err| {
- log.warn("unable to create new window: {t}", .{err});
};
}
pub fn actionOpenConfig(
_: *gio.SimpleAction,
- _: ?*glib.Variant,
+ parameter_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
- _ = self.core().mailbox.push(.open_config, .forever);
+ const target: CoreApp.Message.OpenConfig = target: {
+ const target_map: std.StaticStringMap(CoreApp.Message.OpenConfig) = .initComptime(&.{
+ .{ "os-open", .os_open },
+ .{ "new-window", .new_window },
+ });
+
+ const parameter = parameter_ orelse {
+ log.warn("no parameter provided to app.open-config action", .{});
+ break :target .os_open;
+ };
+
+ const s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
+ defer s_variant_type.free();
+
+ if (parameter.isOfType(s_variant_type) == 0) {
+ log.warn("parameter to app.open-config action is of type '{s}' not '{s}'", .{
+ parameter.getTypeString(),
+ s_variant_type.peekString()[0..s_variant_type.getStringLength()],
+ });
+ break :target .os_open;
+ }
+
+ var len: usize = undefined;
+ const buf = parameter.getString(&len);
+ const str = buf[0..len];
+
+ break :target target_map.get(str) orelse {
+ log.warn("'{s}' is not configured as a target for the app.open-config action", .{str});
+ break :target .os_open;
+ };
+ };
+
+ _ = self.core().mailbox.push(global.io(), .{ .open_config = target }, .forever);
}
fn actionPresentSurface(
@@ -1839,6 +2125,7 @@ pub const Application = extern struct {
const surface = self.core().findSurfaceByID(surface_id) orelse return;
_ = self.core().mailbox.push(
+ global.io(),
.{
.surface_message = .{
.surface = surface,
@@ -1849,6 +2136,40 @@ pub const Application = extern struct {
);
}
+ pub fn actionRingBell(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Self,
+ ) callconv(.c) void {
+ const priv: *Private = self.private();
+ const config = priv.config.get();
+
+ // Do our sound
+ if (config.@"bell-features".audio) audio: {
+ const config_path = config.@"bell-audio-path" orelse break :audio;
+ const path, const required = switch (config_path) {
+ .optional => |path| .{ path, false },
+ .required => |path| .{ path, true },
+ };
+
+ const volume = std.math.clamp(
+ config.@"bell-audio-volume",
+ 0.0,
+ 1.0,
+ );
+
+ // Reuse one MediaFile per application (rebuilt only when the path
+ // changes) so each bell replays the same pipeline instead of
+ // leaking a fresh one. Assign unconditionally: bellMediaFile frees
+ // any stale MediaFile and returns the current slot value (possibly
+ // null if the path is now inaccessible), so priv.bell_media never
+ // dangles.
+ priv.bell_media = media.bellMediaFile(priv.bell_media, path, required);
+ const media_file = priv.bell_media orelse break :audio;
+ media.playBell(media_file, volume);
+ }
+ }
+
//----------------------------------------------------------------
// Boilerplate/Noise
@@ -1866,11 +2187,8 @@ pub const Application = extern struct {
fn init(class: *Class) callconv(.c) void {
// Register our compiled resources exactly once.
{
- const c = @cImport({
- // generated header files
- @cInclude("ghostty_resources.h");
- });
- if (c.ghostty_get_resource()) |ptr| {
+ const ghostty_gtk_resources = @import("ghostty_gtk_resources");
+ if (ghostty_gtk_resources.ghostty_get_resource()) |ptr| {
gio.resourcesRegister(@ptrCast(@alignCast(ptr)));
} else {
// If we fail to load resources then things will
@@ -1894,11 +2212,11 @@ pub const Application = extern struct {
};
pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void {
+ _ = self;
// Fallback to the minimal cross-platform way of opening a URL.
// This is always a safe fallback and enables for example Windows
// to open URLs (GTK on Windows via WSL is a thing).
internal_os.open(
- self.allocator(),
kind,
url,
) catch |err| log.warn("unable to open url: {}", .{err});
@@ -1938,6 +2256,98 @@ const Action = struct {
};
}
+ pub fn exportTerminalIO(
+ self: *Application,
+ target: apprt.Target,
+ value: apprt.Action.Value(.export_terminal_io),
+ ) Allocator.Error!bool {
+ const surface = switch (target) {
+ .app => return false,
+ .surface => |v| v.rt_surface.gobj(),
+ };
+
+ const alloc = self.allocator();
+ const contents = try alloc.dupe(u8, value.contents);
+ errdefer alloc.free(contents);
+ const request = try alloc.create(ExportTerminalIORequest);
+ errdefer alloc.destroy(request);
+ request.* = .{
+ .alloc = alloc,
+ .contents = contents,
+ };
+
+ const parent = ext.getAncestor(gtk.Window, surface.as(gtk.Widget));
+ const dialog = gtk.FileChooserNative.new(
+ i18n._("Export Terminal IO Events"),
+ parent,
+ .save,
+ i18n._("Export"),
+ i18n._("Cancel"),
+ );
+ const chooser = dialog.as(gtk.FileChooser);
+ chooser.setCreateFolders(1);
+ chooser.setCurrentName("ghostty-terminal-io.txt");
+
+ _ = gtk.NativeDialog.signals.response.connect(
+ dialog,
+ *ExportTerminalIORequest,
+ ExportTerminalIORequest.response,
+ request,
+ .{ .destroyData = ExportTerminalIORequest.destroy },
+ );
+ dialog.as(gtk.NativeDialog).show();
+ return true;
+ }
+
+ const ExportTerminalIORequest = struct {
+ alloc: Allocator,
+ contents: []u8,
+
+ fn destroy(self: *ExportTerminalIORequest) callconv(.c) void {
+ self.alloc.free(self.contents);
+ self.alloc.destroy(self);
+ }
+
+ fn response(
+ dialog: *gtk.FileChooserNative,
+ response_id: c_int,
+ self: *ExportTerminalIORequest,
+ ) callconv(.c) void {
+ defer dialog.unref();
+
+ if (response_id != @intFromEnum(gtk.ResponseType.accept)) return;
+
+ const file = dialog.as(gtk.FileChooser).getFile() orelse {
+ log.warn("inspector export dialog returned no file", .{});
+ return;
+ };
+ defer file.unref();
+
+ var gerr: ?*glib.Error = null;
+ const replaced = file.replaceContents(
+ self.contents.ptr,
+ self.contents.len,
+ null,
+ 0,
+ .{},
+ null,
+ null,
+ &gerr,
+ );
+ if (gerr) |err| {
+ defer err.free();
+ log.err(
+ "failed to export terminal IO events err={s}",
+ .{err.f_message orelse "(unknown)"},
+ );
+ return;
+ }
+ if (replaced == 0) {
+ log.err("failed to export terminal IO events", .{});
+ }
+ }
+ };
+
pub fn configChange(
self: *Application,
target: apprt.Target,
@@ -2202,6 +2612,26 @@ const Action = struct {
}
}
+ pub fn moveTabToNewWindow(
+ target: apprt.Target,
+ ) bool {
+ switch (target) {
+ .app => return false,
+ .surface => |core| {
+ const surface = core.rt_surface.surface;
+ const window = ext.getAncestor(
+ Window,
+ surface.as(gtk.Widget),
+ ) orelse {
+ log.warn("surface is not in a window, ignoring new_tab_to_new_window", .{});
+ return false;
+ };
+
+ return window.moveTabToNewWindow(surface);
+ },
+ }
+ }
+
pub fn newSplit(
target: apprt.Target,
direction: apprt.action.SplitDirection,
@@ -2224,7 +2654,17 @@ const Action = struct {
}
}
- pub fn newTab(target: apprt.Target) bool {
+ pub fn newTab(
+ target: apprt.Target,
+ overrides: struct {
+ command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ },
+ ) bool {
switch (target) {
.app => {
log.warn("new tab to app is unexpected", .{});
@@ -2243,7 +2683,12 @@ const Action = struct {
log.warn("surface is not in a window, ignoring new_tab", .{});
return false;
};
- window.newTab(core);
+ window.newTab(core, .{
+ .command = overrides.command,
+ .shell_integration = overrides.shell_integration,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ });
return true;
},
}
@@ -2254,6 +2699,7 @@ const Action = struct {
parent: ?*CoreSurface,
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -2276,6 +2722,7 @@ const Action = struct {
parent,
.{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
},
@@ -2288,6 +2735,7 @@ const Action = struct {
parent: ?*CoreSurface,
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -2308,6 +2756,7 @@ const Action = struct {
// Create a new tab with window context (first tab in new window)
win.newTabForWindow(parent, .{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
});
@@ -2328,18 +2777,55 @@ const Action = struct {
gtk.Window.present(win.as(gtk.Window));
}
- pub fn openConfig(self: *Application) bool {
- // Get the config file path
+ pub fn openConfig(self: *Application, value: apprt.action.OpenConfig) bool {
const alloc = self.allocator();
+
+ // Get the config file path
const path = configpkg.edit.openPath(alloc) catch |err| {
- log.warn("error getting config file path: {}", .{err});
+ log.warn("error getting config file path: {t}", .{err});
return false;
};
defer alloc.free(path);
- // Open it using openURL. "path" isn't actually a URL but
- // at the time of writing that works just fine for GTK.
- openUrl(self, .{ .kind = .text, .url = path });
+ switch (value) {
+ .os_open => {
+ // Open it using openUrl. "path" isn't actually a URL but
+ // at the time of writing that works just fine for GTK.
+ openUrl(self, .{ .kind = .text, .url = path });
+ },
+
+ .new_window => {
+ const cmd = internal_os.getConfigEditCommand(alloc, path, .default) catch |err| {
+ log.warn("unable to get command to edit the config: {t}", .{err});
+ return false;
+ };
+ defer alloc.free(cmd);
+
+ const command: configpkg.Command = .{
+ .direct = &.{ "/bin/sh", "-c", cmd },
+ };
+
+ const title = std.fmt.allocPrintSentinel(
+ alloc,
+ "{s} {s}",
+ .{ i18n._("Editing configuration file"), path },
+ 0,
+ ) catch |err| t: {
+ log.warn("unable to format title: {t}", .{err});
+ break :t null;
+ };
+ defer if (title) |t| alloc.free(t);
+
+ Action.newWindow(self, null, .{
+ .command = command,
+ .title = title,
+ }) catch |err| {
+ log.warn("unable to create new window: {t}", .{err});
+ return false;
+ };
+ },
+ }
+
return true;
}
@@ -2434,6 +2920,23 @@ const Action = struct {
},
}
},
+ .window => {
+ switch (target) {
+ .app => return false,
+ .surface => |v| {
+ const surface = v.rt_surface.surface;
+ const win = ext.getAncestor(
+ Window,
+ surface.as(gtk.Widget),
+ ) orelse {
+ log.warn("surface is not in a window, ignoring prompt_window_title", .{});
+ return false;
+ };
+ win.promptWindowTitle();
+ return true;
+ },
+ }
+ },
}
}
@@ -2605,6 +3108,30 @@ const Action = struct {
}
}
+ pub fn setWindowTitle(
+ target: apprt.Target,
+ value: apprt.action.SetTitle,
+ ) bool {
+ switch (target) {
+ .app => {
+ log.warn("set_tab_title to app is unexpected", .{});
+ return false;
+ },
+ .surface => |core| {
+ const surface = core.rt_surface.surface;
+ const window = ext.getAncestor(
+ Window,
+ surface.as(gtk.Widget),
+ ) orelse {
+ log.warn("surface is not in a window, ignoring set_window_title", .{});
+ return false;
+ };
+ window.setTitleOverride(if (value.title.len == 0) null else value.title);
+ return true;
+ },
+ }
+ }
+
pub fn showChildExited(
target: apprt.Target,
value: apprt.surface.Message.ChildExited,
@@ -2846,7 +3373,7 @@ const Action = struct {
/// given the runtime environment or configuration.
///
/// This must be called BEFORE GTK initialization.
-fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
+fn setGtkEnv(config: *const CoreConfig) std.Io.Writer.Error!void {
assert(gtk.isInitialized() == 0);
var gdk_debug: struct {
@@ -2863,7 +3390,7 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
} = .{
// `gtk-opengl-debug` dumps logs directly to stderr so both must be true
// to enable OpenGL debugging.
- .opengl = state.logging.stderr and config.@"gtk-opengl-debug",
+ .opengl = global.logging().stderr and config.@"gtk-opengl-debug",
};
var gdk_disable: struct {
@@ -2915,8 +3442,7 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
{
var buf: [1024]u8 = undefined;
- var fmt = std.io.fixedBufferStream(&buf);
- const writer = fmt.writer();
+ var writer: std.Io.Writer = .fixed(&buf);
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| {
if (@field(gdk_debug, field.name)) {
@@ -2926,15 +3452,14 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
}
}
try writer.writeByte(0);
- const value = fmt.getWritten();
+ const value = writer.buffered();
log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]});
- _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]);
+ _ = setenv("GDK_DEBUG", @ptrCast(value[0 .. value.len - 1 :0]), 1);
}
{
var buf: [1024]u8 = undefined;
- var fmt = std.io.fixedBufferStream(&buf);
- const writer = fmt.writer();
+ var writer: std.Io.Writer = .fixed(&buf);
var first: bool = true;
inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| {
if (@field(gdk_disable, field.name)) {
@@ -2944,10 +3469,13 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
}
}
try writer.writeByte(0);
- const value = fmt.getWritten();
+ const value = writer.buffered();
log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]});
- _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]);
+ _ = setenv("GDK_DISABLE", @ptrCast(value[0 .. value.len - 1 :0]), 1);
}
+
+ // Sync environ after altering system env
+ global.syncEnviron();
}
fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig
index d44d38a3..d993b55c 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig
@@ -193,11 +193,11 @@ pub const ClipboardConfirmationDialog = extern struct {
const priv = self.private();
const req = priv.request orelse return;
switch (req.*) {
- .osc_52_write => {
+ .osc_52_write, .kitty_write => {
self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below."));
},
- .osc_52_read => {
+ .osc_52_read, .kitty_read => {
self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access"));
self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below."));
},
@@ -205,6 +205,7 @@ pub const ClipboardConfirmationDialog = extern struct {
self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste"));
self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."));
},
+ .list => unreachable,
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig
index 6101e82e..aaec0058 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig
@@ -153,7 +153,7 @@ pub const CommandPalette = extern struct {
priv.source.removeAll();
const alloc = Application.default().allocator();
- var commands: std.ArrayList(*Command) = .{};
+ var commands: std.ArrayList(*Command) = .empty;
defer {
for (commands.items) |cmd| cmd.unref();
commands.deinit(alloc);
@@ -581,10 +581,8 @@ const Command = extern struct {
const priv = self.private();
priv.data = .{
- .jump = .{
- // TODO: Replace with surface id whenever Ghostty adds one
- .sort_key = @intFromPtr(surface),
- },
+ // Surface should be initialized at this point.
+ .jump = .{ .sort_key = surface.core().?.id },
};
priv.data.jump.surface.set(surface);
@@ -611,7 +609,7 @@ const Command = extern struct {
switch (priv.data) {
.regular => {},
.jump => |*j| {
- j.surface.set(null);
+ j.surface.deinit();
},
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig
index 28987c25..88aaba9f 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig
@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
+const global = @import("../../../global.zig");
const Binding = @import("../../../input.zig").Binding;
const key = @import("../key.zig");
@@ -82,6 +83,24 @@ pub const GlobalShortcuts = extern struct {
pub var offset: c_int = 0;
};
+ /// A global shortcut that failed to bind or was revoked.
+ /// Only valid for the duration of the signal emission.
+ pub const BindFailed = struct {
+ trigger: Binding.Trigger,
+ action: Binding.Action,
+
+ /// May be empty.
+ message: [*:0]const u8,
+
+ /// Revoked after binding rather than denied up front.
+ revoked: bool,
+
+ pub const getGObjectType = gobject.ext.defineBoxed(
+ BindFailed,
+ .{ .name = "GhosttyGlobalShortcutsBindFailed" },
+ );
+ };
+
pub const signals = struct {
/// Emitted whenever a global shortcut is triggered.
pub const trigger = struct {
@@ -94,6 +113,18 @@ pub const GlobalShortcuts = extern struct {
void,
);
};
+
+ /// Emitted when a global shortcut fails to bind or is revoked.
+ pub const @"bind-failed" = struct {
+ pub const name = "bind-failed";
+ pub const connect = impl.connect;
+ const impl = gobject.ext.defineSignal(
+ name,
+ Self,
+ &.{*const BindFailed},
+ void,
+ );
+ };
};
fn init(self: *Self, _: *Class) callconv(.c) void {
@@ -109,6 +140,10 @@ pub const GlobalShortcuts = extern struct {
fn close(self: *Self) void {
const priv = self.private();
+ // This is safe to call even if the winproto was already
+ // deinitialized, which happens during application teardown.
+ Application.default().winproto().clearGlobalShortcuts();
+
if (priv.dbus_connection) |dbus| {
if (priv.response_subscription != 0) {
dbus.signalUnsubscribe(priv.response_subscription);
@@ -152,10 +187,19 @@ pub const GlobalShortcuts = extern struct {
const priv = self.private();
- // We need a dbus connection and configuration to proceed.
- if (priv.dbus_connection == null) return;
+ // We need a configuration to proceed.
const config = if (priv.config) |v| v.get() else return;
+ // Prefer a windowing protocol native mechanism over the
+ // XDG desktop portal when available.
+ if (Application.default().winproto().bindGlobalShortcuts(self, config)) {
+ log.debug("global shortcuts bound via winproto", .{});
+ return;
+ }
+
+ // The portal fallback requires a dbus connection.
+ if (priv.dbus_connection == null) return;
+
// Setup our new arena that we'll use for memory allocations.
assert(priv.arena == null);
var arena: std.heap.ArenaAllocator = .init(Application.default().allocator());
@@ -558,10 +602,25 @@ pub const GlobalShortcuts = extern struct {
log.debug("activated={s}", .{shortcut_id});
const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return;
+ self.emitTrigger(&action);
+ }
+
+ /// Emit the trigger signal. Public for winproto shortcut backends.
+ pub fn emitTrigger(self: *Self, action: *const Binding.Action) void {
signals.trigger.impl.emit(
self,
null,
- .{&action},
+ .{action},
+ null,
+ );
+ }
+
+ /// Emit the bind-failed signal. Public for winproto shortcut backends.
+ pub fn emitBindFailed(self: *Self, failure: *const BindFailed) void {
+ signals.@"bind-failed".impl.emit(
+ self,
+ null,
+ .{failure},
null,
);
}
@@ -611,6 +670,7 @@ pub const GlobalShortcuts = extern struct {
// Signals
signals.trigger.impl.register(.{});
+ signals.@"bind-failed".impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
@@ -630,6 +690,9 @@ fn generateToken(buf: *Token) [:0]const u8 {
return std.fmt.bufPrintZ(
buf,
"ghostty_{x:0<7}",
- .{std.crypto.random.int(u28)},
+ .{rand_int: {
+ const rng_impl: std.Random.IoSource = .{ .io = global.io() };
+ break :rand_int rng_impl.interface().int(u28);
+ }},
) catch unreachable;
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig
index 0ef753a8..50247fbf 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig
@@ -7,6 +7,7 @@ const adw = @import("adw");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
+const global = @import("../../../global.zig");
const input = @import("../../../input.zig");
const gresource = @import("../build/gresource.zig");
@@ -61,13 +62,13 @@ pub const ImguiWidget = extern struct {
ig_context: ?*cimgui.c.ImGuiContext = null,
/// Our previous instant used to calculate delta time for animations.
- instant: ?std.time.Instant = null,
+ instant: ?std.Io.Timestamp = null,
/// Tick callback ID for timed updates.
tick_callback_id: c_uint = 0,
/// Last render time for throttling to 30 FPS.
- last_render_time: ?std.time.Instant = null,
+ last_render_time: ?std.Io.Timestamp = null,
pub var offset: c_int = 0;
};
@@ -140,10 +141,11 @@ pub const ImguiWidget = extern struct {
const priv = self.private();
const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO();
+ const now: std.Io.Timestamp = .now(global.io(), .awake);
+
// Determine our delta time
- const now = std.time.Instant.now() catch unreachable;
io.DeltaTime = if (priv.instant) |prev| delta: {
- const since_ns: f64 = @floatFromInt(now.since(prev));
+ const since_ns: f64 = @floatFromInt(prev.durationTo(now).nanoseconds);
const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s);
const since_s: f32 = @floatCast(since_ns / ns_per_s);
break :delta @max(0.00001, since_s);
@@ -298,7 +300,7 @@ pub const ImguiWidget = extern struct {
// Update last render time for tick callback throttling.
const priv = self.private();
- priv.last_render_time = std.time.Instant.now() catch null;
+ priv.last_render_time = .now(global.io(), .awake);
// Setup our frame. We render twice because some ImGui behaviors
// take multiple renders to process. I don't know how to make this
@@ -457,15 +459,10 @@ pub const ImguiWidget = extern struct {
const self: *Self = gobject.ext.cast(Self, widget) orelse return 0;
const priv = self.private();
- const now = std.time.Instant.now() catch {
- self.queueRender();
- return 1;
- };
-
// Throttle to 30 FPS (~33ms between frames)
const frame_time_ns: u64 = std.time.ns_per_s / 30;
const should_render = if (priv.last_render_time) |last|
- now.since(last) >= frame_time_ns
+ last.untilNow(global.io(), .awake).nanoseconds >= frame_time_ns
else
true;
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
index 7a6b66de..ca743712 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig
@@ -214,6 +214,7 @@ pub const SplitTree = extern struct {
parent_: ?*Surface,
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -225,6 +226,7 @@ pub const SplitTree = extern struct {
// Create our new surface.
const surface: *Surface = .new(.{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
});
@@ -239,12 +241,7 @@ pub const SplitTree = extern struct {
}
// Bind is-split property for new surface
- _ = self.as(gobject.Object).bindProperty(
- "is-split",
- surface.as(gobject.Object),
- "is-split",
- .{ .sync_create = true },
- );
+ surface.bindIsSplit(self);
// Create our tree
var single_tree = try Surface.Tree.init(alloc, surface);
@@ -387,6 +384,77 @@ pub const SplitTree = extern struct {
return true;
}
+ /// Move the source split onto the target split in a given direction.
+ /// The target split must be located within this tree.
+ pub fn moveSplit(
+ self: *Self,
+ source: *Surface,
+ target: *Surface,
+ dir: Surface.Tree.Split.Direction,
+ ) Allocator.Error!void {
+ const alloc = Application.default().allocator();
+ const target_tree = self.getTree() orelse return;
+
+ // This really shouldn't fail, but just in case
+ const target_handle = target_tree.locate(target) orelse {
+ log.warn("target is not placed in a split tree", .{});
+ return;
+ };
+
+ // Try to find the source within the current tree.
+ // If it exists, then it's a local move; otherwise the logic gets more
+ // complicated
+ const source_handle = target_tree.locate(source);
+
+ // First add the source into this tree.
+ // We have to do this no matter what
+ var branch = try Surface.Tree.init(alloc, source);
+ defer branch.deinit();
+
+ var after_split = try target_tree.split(
+ alloc,
+ target_handle,
+ dir,
+ 0.5,
+ &branch,
+ );
+ defer after_split.deinit();
+
+ if (source_handle) |handle| {
+ // Happy path: source and target are located within the same tree.
+ // In that case, we just remove the existing source from this tree
+
+ var after_remove = try after_split.remove(alloc, handle);
+ defer after_remove.deinit();
+
+ self.setTree(&after_remove);
+ } else {
+ // :( Cross-tree moves are a bit more complicated.
+
+ // TODO: Find a better way to access the split tree from here
+ const source_tree_widget = ext.getAncestor(
+ SplitTree,
+ source.as(gtk.Widget),
+ ) orelse {
+ log.warn("source is not placed in a split tree", .{});
+ return;
+ };
+ const source_tree = source_tree_widget.getTree() orelse return;
+
+ // Remove the source from its own tree
+ const handle = source_tree.locate(source) orelse return;
+ var new_source_tree = try source_tree.remove(alloc, handle);
+ defer new_source_tree.deinit();
+
+ // Finally, set the final tree structures for both tree widgets
+ source_tree_widget.setTree(&new_source_tree);
+ self.setTree(&after_split);
+
+ // Re-bind vital properties like `is-split`
+ source.bindIsSplit(self);
+ }
+ }
+
fn disconnectSurfaceHandlers(self: *Self) void {
const tree = self.getTree() orelse return;
var it = tree.iterator();
@@ -579,7 +647,7 @@ pub const SplitTree = extern struct {
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
- priv.last_focused.set(null);
+ priv.last_focused.deinit();
if (priv.rebuild_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove rebuild source", .{});
@@ -705,17 +773,8 @@ pub const SplitTree = extern struct {
// Find the surface in the tree to verify this is valid and
// set our pending close handle.
- priv.pending_close = handle: {
- const tree = self.getTree() orelse return;
- var it = tree.iterator();
- while (it.next()) |entry| {
- if (entry.view == surface) {
- break :handle entry.handle;
- }
- }
-
- return;
- };
+ const tree = self.getTree() orelse return;
+ priv.pending_close = tree.locate(surface) orelse return;
// If we don't need to confirm then just close immediately.
if (!core.needsConfirmQuit()) {
@@ -1115,12 +1174,17 @@ const SplitTreeSplit = extern struct {
/// Assumed to be correct.
handle: Surface.Tree.Node.Handle,
+ /// Cache the split ratio. Looking it up is relatively expensive
+ /// because we have to first walk the widget tree to find the
+ /// SplitTree widget.
+ ratio: f64,
+
/// Source to handle repositioning the split when properties change.
idle: ?c_uint = null,
/// Whether the max-position/position property of the gtk.Paned widget
/// changed. We use these to distinguish between a resize and the user
- /// manually moving the split divider. See the "on-idle" function.
+ /// manually moving the split divider. See the "onIdle" function.
max_changed: bool = false,
pos_changed: bool = false,
@@ -1144,6 +1208,7 @@ const SplitTreeSplit = extern struct {
const self = gobject.ext.newInstance(Self, .{});
const priv = self.private();
priv.handle = handle;
+ priv.ratio = split.ratio;
// Setup our paned fields
const paned = priv.paned;
@@ -1154,6 +1219,17 @@ const SplitTreeSplit = extern struct {
.vertical => .vertical,
});
+ // Request min width/height 1 for surfaces to prevent them from
+ // becoming temporarily invisible in nested layouts. Otherwise
+ // gtk.Paned might mark a surface as not visible and unmap it
+ // for a single frame. See the comments below in propMaxPosition.
+ if (gobject.ext.isA(start_child, SurfaceScrolledWindow)) {
+ start_child.setSizeRequest(1, 1);
+ }
+ if (gobject.ext.isA(end_child, SurfaceScrolledWindow)) {
+ end_child.setSizeRequest(1, 1);
+ }
+
// Signals and so on are setup in the template.
return self;
@@ -1163,49 +1239,18 @@ const SplitTreeSplit = extern struct {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
- // We need to keep the split ratios from the tree datastructure and
- // widget tree in sync. Using the max-position and position properties
- // of the gtk.Paned widget, we can distinguish a resize from a manual
- // update (e.g. the user dragging the divider).If max-position changes,
- // we always have a widget resize. Usually position will change as well
- // but it might not if the size change is small enough. If only position
- // changes, we have a manual human update.
- //
- // This is a hack, it relies on the timing of property notifcations.
- // From looking at the GTK source code, it should not be possible that
- // we interpret a position change from a resize as a manual update.
- // When a gtk.Paned is resized, internally the gtk_paned_calc_position
- // function will change both max-position and position and synchronously
- // call our propMaxPosition and propPosition functions. I.e. when the
- // widget is resized, it should not be possible for onIdle to run before
- // we have been notified of both property changes.
- fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
- const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const SyncDirection = enum {
+ // Update gtk.Paned widget to match ratio from split tree node.
+ tree_to_widget,
+ // Update split tree node to match ratio from gtk.Paned widget.
+ widget_to_tree,
+ };
+
+ // Sync split ratio between the gtk.Paned widget and the split tree.
+ fn syncSplitRatio(self: *Self, direction: SyncDirection) void {
const priv = self.private();
const paned = priv.paned;
- // Clear source and fields at the end. Otherwise if setPosition is
- // called below, propPosition is triggered and would add another
- // idle callback before this one is finished.
- defer priv.idle = null;
- defer priv.max_changed = false;
- defer priv.pos_changed = false;
-
- if (!priv.max_changed and !priv.pos_changed) {
- return 0;
- }
-
- // Get our split. This is the most dangerous part of this entire
- // widget. We assume that this widget is always a child of a
- // SplitTree, we assume that our handle is valid, and we assume
- // the handle is always a split node.
- const split_tree = ext.getAncestor(
- SplitTree,
- self.as(gtk.Widget),
- ) orelse return 0;
- const tree = split_tree.getTree() orelse return 0;
- const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split;
-
// Current, min, and max positions as pixels.
const pos = paned.getPosition();
const min = min: {
@@ -1236,7 +1281,7 @@ const SplitTreeSplit = extern struct {
// If our max is zero then we can't do any math. I don't know
// if this is possible but I suspect it can be if you make a nested
// split completely minimized.
- if (max == 0) return 0;
+ if (max == 0) return;
// Determine our current ratio.
const current_ratio: f64 = ratio: {
@@ -1244,7 +1289,7 @@ const SplitTreeSplit = extern struct {
const max_f64: f64 = @floatFromInt(max);
break :ratio pos_f64 / max_f64;
};
- const desired_ratio: f64 = @floatCast(split.ratio);
+ const desired_ratio: f64 = priv.ratio;
// If our ratio is close enough to our desired ratio, then
// we ignore the update. This is to avoid constant split updates
@@ -1255,24 +1300,85 @@ const SplitTreeSplit = extern struct {
desired_ratio,
0.001,
)) {
- return 0;
+ return;
+ }
+
+ switch (direction) {
+ .tree_to_widget => {
+ // Update position in gtk.Paned to match desired ratio. Note that if
+ // max-position is small, it might not be possible to accurately set
+ // the desired ratio. E.g. with max-position=2 you can only have
+ // ratios 0, 0.5 and 1.
+ const desired_pos: c_int = desired_pos: {
+ const max_f64: f64 = @floatFromInt(max);
+ break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
+ };
+ paned.setPosition(desired_pos);
+ },
+ .widget_to_tree => {
+ // Update ratio of the split tree node. This is the most dangerous
+ // part of this widget. We assume that this widget is always a child
+ // of a SplitTree, we assume that our handle is valid, and we assume
+ // the handle is always a split node.
+ const split_tree = ext.getAncestor(
+ SplitTree,
+ self.as(gtk.Widget),
+ ) orelse {
+ log.warn("SplitTree ancestor not found", .{});
+ return;
+ };
+ const tree = split_tree.getTree() orelse {
+ log.warn("no tree data model set", .{});
+ return;
+ };
+ assert(priv.handle.idx() < tree.nodes.len);
+ assert(tree.nodes[priv.handle.idx()] == .split);
+ tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
+ priv.ratio = current_ratio;
+ },
+ }
+ }
+
+ // We need to keep the split ratios from the tree datastructure and
+ // widget tree in sync. Using the max-position and position properties
+ // of the gtk.Paned widget, we can distinguish a resize from a manual
+ // update (e.g. the user dragging the divider). If max-position changes,
+ // we always have a widget resize and we sync the ratio directly from
+ // the propMaxPosition function. Usually position will change as well
+ // but it might not if the size change is small enough. If only position
+ // changes, we have a manual human update and we sync the ratio here.
+ //
+ // This is a hack, it relies on the timing of property notifcations.
+ // From looking at the GTK source code, it should not be possible that
+ // we interpret a position change from a resize as a manual update.
+ // When a gtk.Paned is resized, internally the gtk_paned_calc_position
+ // function will change both max-position and position and synchronously
+ // call our propMaxPosition and propPosition functions. I.e. when the
+ // widget is resized, it should not be possible for onIdle to run before
+ // we have been notified of both property changes.
+ fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
+ const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
+ const priv = self.private();
+
+ // Clear source and fields.
+ defer {
+ priv.idle = null;
+ priv.max_changed = false;
+ priv.pos_changed = false;
}
if (priv.max_changed) {
- // Widget got resized, update position to match desired ratio.
- // Note that if max-position is small, it might not be possible
- // to accurately set the desired ratio. E.g. with max-position=2
- // you can only have ratios 0, 0.5 and 1.
- const desired_pos: c_int = desired_pos: {
- const max_f64: f64 = @floatFromInt(max);
- break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
- };
- paned.setPosition(desired_pos);
- } else {
- // If only position changed, this is a manual human update and
- // we need to write our update back to the tree.
- tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
+ // Nothing to do, syncSplitRatio was already called
+ // directly from propMaxPosition.
+ return 0;
+ }
+ if (!priv.pos_changed) {
+ return 0;
}
+
+ // If only position changed, this is a manual human update and
+ // we need to write our update back to the tree.
+ self.syncSplitRatio(.widget_to_tree);
return 0;
}
@@ -1286,6 +1392,41 @@ const SplitTreeSplit = extern struct {
) callconv(.c) void {
const priv = self.private();
priv.max_changed = true;
+
+ // Widget got resized, update position to match desired ratio.
+ //
+ // Syncing the split ratio directly here ensures that every surface
+ // gets the correct size allocated immediately. This works because
+ // this callback runs during size allocation, so that we update the
+ // divider position right after the gtk.Paned widget computes it and
+ // right before it will use the position to compute the sizes of its
+ // children. See the size_allocate and calc_position functions of the
+ // gtk.Paned widget.
+ //
+ // If we synced the ratio in an idle callback, there might be some
+ // flickering as surfaces are shown with the wrong size for a few
+ // frames until our idle callback runs. Depending on the split layout
+ // it might take multiple rounds of size allocation and idle callbacks
+ // for every widget to get the correct size.
+ //
+ // Note that gtk.Paned calls Widget.setChildVisible(true/false) for
+ // both children based on the initial divider position it computes.
+ // If we then update the position here, we might sometimes have to
+ // call setChildVisible again. This would be necessary if we updated
+ // the position from initially 0 (max), where start (end) child is
+ // invisible, to a value > 0 (< max). However, we can skip this because
+ // set_position already queues another round of size allocation, during
+ // which gtk.Paned will keep the correct position we set manually and
+ // then call setChildVisible again based on that. The only problematic
+ // situation is when we update the position from 0 to a value > 0,
+ // because setChildVisible(false) will unmap the start child widget.
+ // It will not be visible for a single frame which introduces some
+ // flickering. To prevent this from happening we set a min size request
+ // of 1 for all surfaces (see the "new" function above).
+ self.syncSplitRatio(.tree_to_widget);
+
+ // We still need the idle callback to clear the max_changed field
+ // later, because propPosition might or might not still get called.
if (priv.idle == null) priv.idle = glib.idleAdd(
onIdle,
self,
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
index 54825ce0..9d45bc6c 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig
@@ -34,8 +34,10 @@ const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig")
const TitleDialog = @import("title_dialog.zig").TitleDialog;
const Window = @import("window.zig").Window;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
+const SplitTree = @import("split_tree.zig").SplitTree;
const i18n = @import("../../../os/i18n.zig");
-const media = @import("../media.zig");
+const global = @import("../../../global.zig");
+const gtk_version = @import("../gtk_version.zig");
const log = std.log.scoped(.gtk_ghostty_surface);
@@ -674,12 +676,6 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
- // The audio bell's MediaFile, reused across bells so we don't leak a
- // GStreamer pipeline (and its GL threads) on every ring. Built lazily
- // on the first audio bell and rebuilt when `bell-audio-path` changes;
- // unref'd on dispose. See ringBell and media.zig.
- bell_media: ?*gtk.MediaFile = null,
-
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
@@ -695,6 +691,7 @@ pub const Surface = extern struct {
// True if the current surface is a split, this is used to apply
// unfocused-split-* options
is_split: bool = false,
+ is_split_binding: ?*gobject.Binding = null,
action_group: ?*gio.SimpleActionGroup = null,
@@ -713,6 +710,9 @@ pub const Surface = extern struct {
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
drop_target: *gtk.DropTarget,
+ surface_drop_target: *gtk.DropTarget,
+ drag_handle: *gtk.Widget,
+ drop_overlay: *gtk.Widget,
progress_bar_overlay: *gtk.ProgressBar,
error_page: *adw.StatusPage,
terminal_page: *gtk.Overlay,
@@ -736,6 +736,7 @@ pub const Surface = extern struct {
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
pub const none: @This() = .{};
@@ -746,6 +747,7 @@ pub const Surface = extern struct {
pub fn new(overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -758,6 +760,7 @@ pub const Surface = extern struct {
const priv: *Private = self.private();
priv.overrides = .{
.command = if (overrides.command) |c| c.clone(alloc) catch null else null,
+ .shell_integration = overrides.shell_integration,
.working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null,
};
return self;
@@ -848,6 +851,28 @@ pub const Surface = extern struct {
return @intFromBool(config.@"bell-features".border);
}
+ pub fn bindIsSplit(self: *Self, tree: *SplitTree) void {
+ const priv = self.private();
+ if (priv.is_split_binding) |binding| {
+ binding.unbind();
+ binding.unref();
+ priv.is_split_binding = null;
+ }
+
+ const binding = tree.as(gobject.Object).bindProperty(
+ "is-split",
+ self.as(gobject.Object),
+ "is-split",
+ .{ .sync_create = true },
+ );
+ // The ref created by bindProperty is owned by the binding itself.
+ // We need another ref to prevent the binding object from being
+ // freed if the source object (SplitTree) is finalized. Otherwise
+ // our pointer to the binding could become stale.
+ binding.ref();
+ priv.is_split_binding = binding;
+ }
+
/// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity
/// should be applied to the surface
fn closureShouldUnfocusedSplitBeShown(
@@ -1363,19 +1388,11 @@ pub const Surface = extern struct {
if (entry.native == keycode) break :w3c entry.key;
} else .unidentified;
- // Consult the pre-remapped XKB keyval/keysym to get the (possibly)
- // remapped key. If the W3C key or the remapped key
- // is eligible for remapping, we use it.
- //
- // See the docs for `shouldBeRemappable` for why we even have to
- // do this in the first place.
- if (gtk_key.keyFromKeyval(keyval)) |remapped| {
- if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable())
- break :keycode remapped;
- }
-
- // Return the original physical key
- break :keycode w3c_key;
+ break :keycode gtk_key.remapKey(
+ w3c_key,
+ keyval,
+ key_event.isModifier() != 0,
+ );
};
// Get our modifier for the event
@@ -1560,7 +1577,11 @@ pub const Surface = extern struct {
// https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421
// https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead
if (gtk_xft_dpi <= 0) {
- log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi});
+ // -1 is a valid value which specifies default scale.
+ // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html
+ if (gtk_xft_dpi != -1) {
+ log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi});
+ }
break :xft_scale 1.0;
}
@@ -1589,32 +1610,35 @@ pub const Surface = extern struct {
return self.private().cursor_pos;
}
- pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
+ pub fn defaultTermioEnv(self: *Self) !std.process.Environ.Map {
const app = Application.default();
const alloc = app.allocator();
- var env = try internal_os.getEnvMap(alloc);
+ var env = if (internal_os.isFlatpak())
+ std.process.Environ.Map.init(alloc)
+ else
+ try global.environMap();
errdefer env.deinit();
if (app.savedLanguage()) |language| {
try env.put("LANG", language);
} else {
- env.remove("LANG");
+ _ = env.orderedRemove("LANG");
}
// Don't leak these GTK environment variables to child processes.
- env.remove("GDK_DEBUG");
- env.remove("GDK_DISABLE");
- env.remove("GSK_RENDERER");
+ _ = env.orderedRemove("GDK_DEBUG");
+ _ = env.orderedRemove("GDK_DISABLE");
+ _ = env.orderedRemove("GSK_RENDERER");
// Remove some environment variables that are set when Ghostty is launched
// from a `.desktop` file, by D-Bus activation, or systemd.
- env.remove("GIO_LAUNCHED_DESKTOP_FILE");
- env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
- env.remove("DBUS_STARTER_ADDRESS");
- env.remove("DBUS_STARTER_BUS_TYPE");
- env.remove("INVOCATION_ID");
- env.remove("JOURNAL_STREAM");
- env.remove("NOTIFY_SOCKET");
+ _ = env.orderedRemove("GIO_LAUNCHED_DESKTOP_FILE");
+ _ = env.orderedRemove("GIO_LAUNCHED_DESKTOP_FILE_PID");
+ _ = env.orderedRemove("DBUS_STARTER_ADDRESS");
+ _ = env.orderedRemove("DBUS_STARTER_BUS_TYPE");
+ _ = env.orderedRemove("INVOCATION_ID");
+ _ = env.orderedRemove("JOURNAL_STREAM");
+ _ = env.orderedRemove("NOTIFY_SOCKET");
// Unset environment varies set by snaps if we're running in a snap.
// This allows Ghostty to further launch additional snaps.
@@ -1641,7 +1665,7 @@ pub const Surface = extern struct {
}
/// Filter out environment variables that start with forbidden prefixes.
- fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void {
+ fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.Environ.Map) !void {
comptime assert(build_config.snap);
const snap_vars = [_][]const u8{
@@ -1708,14 +1732,14 @@ pub const Surface = extern struct {
item.key,
item.value,
);
- for (env_to_remove.items) |key| _ = env_map.remove(key);
+ for (env_to_remove.items) |key| _ = env_map.orderedRemove(key);
}
pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
- ) !bool {
+ ) !apprt.ClipboardReadResult {
return try Clipboard.request(
self,
clipboard_type,
@@ -1825,8 +1849,19 @@ pub const Surface = extern struct {
};
priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len);
+ // Also have to set up the surface drop target to accept other surfaces
+ // (in particular, their surface IDs)
+ var surface_drop_target_types = [_]gobject.Type{
+ gobject.ext.types.uint64,
+ };
+ priv.surface_drop_target.setGtypes(
+ &surface_drop_target_types,
+ surface_drop_target_types.len,
+ );
+
// Setup properties we can't set from our Blueprint file.
self.as(gtk.Widget).setCursorFromName("text");
+ priv.drag_handle.setCursorFromName("grab");
// Initialize our config
self.propConfig(undefined, null);
@@ -1860,9 +1895,10 @@ pub const Surface = extern struct {
priv.config = null;
}
- if (priv.bell_media) |v| {
- v.unref();
- priv.bell_media = null;
+ if (priv.is_split_binding) |binding| {
+ binding.unbind();
+ binding.unref();
+ priv.is_split_binding = null;
}
if (priv.vadj_signal_group) |group| {
@@ -1987,6 +2023,58 @@ pub const Surface = extern struct {
);
}
+ fn snapshot(self: *Self, snap: *gtk.Snapshot) callconv(.c) void {
+ const priv = self.private();
+
+ const blur = blur: {
+ // Native GTK blur is only available since GTK 4.23.3
+ if (gtk_version.runtimeUntil(4, 23, 3)) break :blur null;
+ const config = priv.config orelse break :blur null;
+
+ break :blur switch (config.get().@"background-blur") {
+ .radius => |v| @as(f32, v),
+ .true, .@"macos-glass-regular", .@"macos-glass-clear" => 20.0,
+ .false => null,
+ };
+ };
+
+ if (blur) |b| blur: {
+ // pushCopy and appendPaste are only supported since 4.22.
+ // These two functions are crucial for the blur function
+ // and it cannot be implemented otherwise, so if you compile
+ // Ghostty on an older GTK version, you won't get blur.
+ // Sorry.
+ if (comptime !gtk_version.atLeast(4, 22, 0)) break :blur;
+
+ const width = self.as(gtk.Widget).getWidth();
+ const height = self.as(gtk.Widget).getHeight();
+
+ // Push the current render state (background)
+ // to be appended below
+ snap.pushCopy();
+ defer snap.pop();
+
+ // Apply blur to the copied background
+ snap.pushBlur(b);
+ defer snap.pop();
+
+ snap.appendPaste(&.{
+ .f_origin = .{ .f_x = 0, .f_y = 0 },
+ .f_size = .{
+ .f_width = @floatFromInt(width),
+ .f_height = @floatFromInt(height),
+ },
+ }, 0);
+ }
+
+ // Draw the children normally
+ gtk.Widget.virtual_methods.snapshot.call(
+ Class.parent,
+ self.as(Parent),
+ snap,
+ );
+ }
+
//---------------------------------------------------------------
// Properties
@@ -2213,7 +2301,12 @@ pub const Surface = extern struct {
// Logic around bell reaction happens on every event even if we're
// already in the ringing state.
- if (ringing) self.ringBell();
+ if (ringing) {
+ self.ringBell();
+ // focus clears ringing state, so we should not change state to
+ // ringing if we're already focused
+ if (self.getFocused()) return;
+ }
// Property change only happens on actual state change
const priv = self.private();
@@ -2467,8 +2560,6 @@ pub const Surface = extern struct {
/// Handle bell features that need to happen every time a BEL is received
/// Currently this is audio and system but this could change in the future.
fn ringBell(self: *Self) void {
- const priv = self.private();
-
// Emit the signal
signals.bell.impl.emit(
self,
@@ -2480,33 +2571,7 @@ pub const Surface = extern struct {
// Activate actions if they exist
_ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
_ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
-
- const config = if (priv.config) |c| c.get() else return;
-
- // Do our sound
- if (config.@"bell-features".audio) audio: {
- const config_path = config.@"bell-audio-path" orelse break :audio;
- const path, const required = switch (config_path) {
- .optional => |path| .{ path, false },
- .required => |path| .{ path, true },
- };
-
- const volume = std.math.clamp(
- config.@"bell-audio-volume",
- 0.0,
- 1.0,
- );
-
- // Reuse one MediaFile per surface (rebuilt only when the path
- // changes) so each bell replays the same pipeline instead of
- // leaking a fresh one. Assign unconditionally: bellMediaFile frees
- // any stale MediaFile and returns the current slot value (possibly
- // null if the path is now inaccessible), so priv.bell_media never
- // dangles.
- priv.bell_media = media.bellMediaFile(priv.bell_media, path, required);
- const media_file = priv.bell_media orelse break :audio;
- media.playBell(media_file, volume);
- }
+ _ = self.as(gtk.Widget).activateAction("app.ring-bell", null);
}
//---------------------------------------------------------------
@@ -2815,7 +2880,10 @@ pub const Surface = extern struct {
return;
}
- if (button == .middle and !priv.gtk_enable_primary_paste) {
+ if (button == .middle and
+ !priv.gtk_enable_primary_paste and
+ !core_surface.mouseReportingActive())
+ {
return;
}
@@ -2875,7 +2943,10 @@ pub const Surface = extern struct {
return;
}
- if (button == .middle and !priv.gtk_enable_primary_paste) {
+ if (button == .middle and
+ !priv.gtk_enable_primary_paste and
+ !surface.mouseReportingActive())
+ {
return;
}
@@ -3320,7 +3391,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(true);
- self.updateOcclusion(true);
+ self.updateOcclusion();
}
fn glareaUnmap(
@@ -3328,7 +3399,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(false);
- self.updateOcclusion(false);
+ self.updateOcclusion();
}
fn updateMapped(self: *Self, mapped: bool) void {
@@ -3337,13 +3408,23 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
}
- fn updateOcclusion(self: *Self, visible: bool) void {
+ /// Update the core surface visibility based on both GTK widget and
+ /// toplevel state. This is public so the window can call it when its
+ /// suspended state changes.
+ pub fn updateOcclusion(self: *Self) void {
const surface = self.core() orelse return;
+ const visible = self.private().mapped and !self.windowSuspended();
surface.occlusionCallback(visible) catch |err| {
log.warn("error in occlusion callback err={}", .{err});
};
}
+ fn windowSuspended(self: *Self) bool {
+ const native = self.as(gtk.Widget).getNative() orelse return false;
+ const window = gobject.ext.cast(gtk.Window, native) orelse return false;
+ return window.isSuspended() != 0;
+ }
+
fn glareaRender(
_: *gtk.GLArea,
_: *gdk.GLContext,
@@ -3461,9 +3542,11 @@ pub const Surface = extern struct {
);
defer config.deinit();
- if (priv.overrides.command) |c| {
- config.command = try c.clone(config._arena.?.allocator());
- }
+ try applyCommandOverrides(
+ &config,
+ priv.overrides.command,
+ priv.overrides.shell_integration,
+ );
if (priv.overrides.working_directory) |wd| {
const config_alloc = config.arenaAlloc();
var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) };
@@ -3597,6 +3680,178 @@ pub const Surface = extern struct {
};
}
+ fn closureShouldDragHandleBeShown(
+ _: *Self,
+ config_: ?*Config,
+ is_split: c_int,
+ ) callconv(.c) c_int {
+ const config = config_ orelse return @intFromBool(false);
+
+ const shown = switch (config.get().@"drag-handle") {
+ .always => true,
+ .auto => is_split != 0,
+ .never => false,
+ };
+ return @intFromBool(shown);
+ }
+
+ fn surfaceDragPrepare(
+ src: *gtk.DragSource,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) *gdk.ContentProvider {
+ // TODO: Use a static content provider once we make `self.core()`
+ // available immediately when constructing the surface widget
+ _ = src;
+ _ = x;
+ _ = y;
+ var val = gobject.ext.Value.newFrom(self.core().?.id);
+ return gdk.ContentProvider.newForValue(&val);
+ }
+
+ fn surfaceDragBegin(
+ src: *gtk.DragSource,
+ _: *gdk.Drag,
+ self: *Self,
+ ) callconv(.c) void {
+ // The scale of the preview
+ const preview_scale: f32 = 0.2;
+
+ // Snapshot the entire surface widget as the icon preview
+ const paintable = gtk.WidgetPaintable.new(self.as(gtk.Widget));
+ defer paintable.unref();
+
+ // Center the preview
+ const width = self.as(gtk.Widget).getWidth();
+ const height = self.as(gtk.Widget).getHeight();
+ const mid_x = @as(f32, @floatFromInt(width)) / 2;
+ const mid_y = @as(f32, @floatFromInt(height)) / 2;
+
+ // Create a snapshot to render the scaled paintable
+ const snap = gtk.Snapshot.new();
+ snap.scale(preview_scale, preview_scale);
+ paintable.as(gdk.Paintable).snapshot(
+ snap.as(gdk.Snapshot),
+ @floatFromInt(width),
+ @floatFromInt(height),
+ );
+
+ if (snap.freeToPaintable(null)) |scaled| {
+ defer scaled.unref();
+ src.setIcon(
+ scaled,
+ @intFromFloat(mid_x * preview_scale),
+ @intFromFloat(mid_y * preview_scale),
+ );
+ } else {
+ // The scaling process somehow failed.
+ // Use the original paintable as a fail-safe
+ log.warn("preview scaling failed - falling back to original paintable", .{});
+ src.setIcon(
+ paintable.as(gdk.Paintable),
+ @intFromFloat(mid_x),
+ @intFromFloat(mid_y),
+ );
+ }
+ }
+
+ fn surfaceDrop(
+ _: *gtk.DropTarget,
+ v: *const gobject.Value,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) void {
+ const dropped_id = v.getUint64();
+ const dropped = self.core().?.app.findSurfaceByID(dropped_id) orelse return;
+ const from = dropped.rt_surface.gobj();
+
+ const st = ext.getAncestor(
+ SplitTree,
+ self.as(gtk.Widget),
+ ) orelse {
+ log.warn("surface is not placed in a split tree", .{});
+ return;
+ };
+
+ const dir = self.calcDropDirection(x, y);
+
+ // The only error that could happen here is an OOM,
+ // and in that case we're already milliseconds away from crashing, so...
+ st.moveSplit(from, self, dir) catch return;
+
+ // Clean up overlay state
+ self.setDropOverlayDirection(null);
+ }
+
+ fn surfaceDropLeave(
+ _: *gtk.DropTarget,
+ self: *Self,
+ ) callconv(.c) void {
+ // Hide overlay
+ self.setDropOverlayDirection(null);
+ }
+
+ fn surfaceDropMotion(
+ _: *gtk.DropTarget,
+ x: f64,
+ y: f64,
+ self: *Self,
+ ) callconv(.c) gdk.DragAction {
+ // Recalculate the drop region
+ const dir = self.calcDropDirection(x, y);
+ self.setDropOverlayDirection(dir);
+ return .{ .move = true };
+ }
+
+ fn propDropValue(
+ tgt: *gtk.DropTarget,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ // Reject the drop if we're dropping a surface onto itself.
+ // Note that we cannot implement this via the `accept` signal,
+ // since the decision of whether to accept or deny a drop is dependent
+ // on the payload (i.e. the surface being dropped). This is
+ // well-documented in GTK docs.
+
+ const core_surface = self.core() orelse return;
+ const value = tgt.getValue() orelse return;
+ const surface_id = value.getUint64();
+ if (core_surface.id == surface_id) tgt.reject();
+ }
+
+ fn setDropOverlayDirection(self: *Self, dir: ?Tree.Split.Direction) void {
+ const priv = self.private();
+ inline for (&.{ "drop-top", "drop-left", "drop-right", "drop-bottom" }) |c| {
+ priv.drop_overlay.removeCssClass(c);
+ }
+
+ if (dir) |d| priv.drop_overlay.addCssClass(switch (d) {
+ .up => "drop-top",
+ .left => "drop-left",
+ .right => "drop-right",
+ .down => "drop-bottom",
+ });
+ }
+
+ fn calcDropDirection(self: *Self, x: f64, y: f64) Tree.Split.Direction {
+ const width: f64 = @floatFromInt(self.as(gtk.Widget).getWidth());
+ const height: f64 = @floatFromInt(self.as(gtk.Widget).getHeight());
+
+ const l_dist = x / width;
+ const t_dist = y / height;
+ const r_dist = 1 - l_dist;
+ const b_dist = 1 - t_dist;
+ const min = @min(l_dist, t_dist, r_dist, b_dist);
+
+ if (min == l_dist) return .left;
+ if (min == r_dist) return .right;
+ if (min == t_dist) return .up;
+ return .down;
+ }
+
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
@@ -3636,6 +3891,9 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("key_state_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
+ class.bindTemplateChildPrivate("surface_drop_target", .{});
+ class.bindTemplateChildPrivate("drag_handle", .{});
+ class.bindTemplateChildPrivate("drop_overlay", .{});
class.bindTemplateChildPrivate("im_context", .{});
// Template Callbacks
@@ -3678,6 +3936,13 @@ pub const Surface = extern struct {
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("search_next_match", &searchNextMatch);
class.bindTemplateCallback("search_previous_match", &searchPreviousMatch);
+ class.bindTemplateCallback("should_drag_handle_be_shown", &closureShouldDragHandleBeShown);
+ class.bindTemplateCallback("surface_drag_prepare", &surfaceDragPrepare);
+ class.bindTemplateCallback("surface_drag_begin", &surfaceDragBegin);
+ class.bindTemplateCallback("surface_drop", &surfaceDrop);
+ class.bindTemplateCallback("surface_drop_leave", &surfaceDropLeave);
+ class.bindTemplateCallback("surface_drop_motion", &surfaceDropMotion);
+ class.bindTemplateCallback("notify_drop_value", &propDropValue);
// Properties
gobject.ext.registerProperties(class, &.{
@@ -3723,6 +3988,7 @@ pub const Surface = extern struct {
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
+ gtk.Widget.virtual_methods.snapshot.implement(class, &snapshot);
}
pub const as = C.Class.as;
@@ -3865,22 +4131,25 @@ const Clipboard = struct {
);
}
- /// Request data from the clipboard (read the clipboard). This
- /// completes asynchronously and will call the `completeClipboardRequest`
- /// core surface API when done.
- ///
- /// Returns true if the request was started, false if the clipboard
- /// doesn't contain text (allowing performable keybinds to pass through).
+ /// Request data from the clipboard (read the clipboard). A started
+ /// request completes asynchronously and will call the
+ /// `completeClipboardRequest` core surface API when done.
pub fn request(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
- ) Allocator.Error!bool {
+ ) Allocator.Error!apprt.ClipboardReadResult {
+ // The GTK apprt doesn't support the Kitty clipboard protocol
+ // yet.
+ if (state == .kitty_read or
+ state == .kitty_write or
+ state == .list) return .unsupported;
+
// Get our requested clipboard
const clipboard = get(
self.private().gl_area.as(gtk.Widget),
clipboard_type,
- ) orelse return false;
+ ) orelse return .unsupported;
// For paste requests, check if clipboard has text format available.
// This is a synchronous check that allows performable keybinds to
@@ -3889,7 +4158,7 @@ const Clipboard = struct {
const formats = clipboard.getFormats();
if (formats.containGtype(gobject.ext.types.string) == 0) {
log.debug("clipboard has no text format, not starting paste request", .{});
- return false;
+ return .unavailable;
}
}
@@ -3912,7 +4181,7 @@ const Clipboard = struct {
ud,
);
- return true;
+ return .started;
}
/// Paste explicit text directly into the surface, regardless of the
@@ -3925,16 +4194,15 @@ const Clipboard = struct {
const surface = self.private().core_surface orelse return;
surface.completeClipboardRequest(
- .paste,
- text,
- false,
+ .{ .paste = .standard },
+ .{ .contents = &.{.{ .mime = "text/plain", .data = text }} },
) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
=> {
showClipboardConfirmation(
self,
- .paste,
+ .{ .paste = .standard },
text,
);
return;
@@ -3978,7 +4246,7 @@ const Clipboard = struct {
.request = &req,
.@"can-remember" = switch (req) {
.osc_52_read, .osc_52_write => true,
- .paste => false,
+ .paste, .list, .kitty_read, .kitty_write => false,
},
.@"clipboard-contents" = contents_buf,
},
@@ -4015,7 +4283,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .allow,
.osc_52_write => surface.config.clipboard_write = .allow,
- .paste => {},
+ .paste, .list, .kitty_read, .kitty_write => {},
};
// Get our text
@@ -4032,11 +4300,10 @@ const Clipboard = struct {
?[:0]const u8,
) orelse return;
- surface.completeClipboardRequest(
- req.*,
- text,
- true,
- ) catch |err| {
+ surface.completeClipboardRequest(req.*, .{
+ .contents = &.{.{ .mime = "text/plain", .data = text }},
+ .confirmed = true,
+ }) catch |err| {
log.warn("failed to complete clipboard request: {}", .{err});
};
}
@@ -4054,7 +4321,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .deny,
.osc_52_write => surface.config.clipboard_write = .deny,
- .paste => @panic("paste should not be able to be remembered"),
+ .paste, .list, .kitty_read, .kitty_write => @panic("request should not be able to be remembered"),
};
}
@@ -4092,8 +4359,7 @@ const Clipboard = struct {
const surface = self.private().core_surface orelse return;
surface.completeClipboardRequest(
req.state,
- str,
- false,
+ .{ .contents = &.{.{ .mime = "text/plain", .data = str }} },
) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
@@ -4144,3 +4410,57 @@ test "computeFraction" {
try std.testing.expectEqual(0.0, computeFraction(0));
try std.testing.expectEqual(0.5, computeFraction(50));
}
+
+/// Apply command and shell integration overrides received from the CLI.
+/// Explicit commands should only receive shell integration when their
+/// executable can be detected as a supported shell. An explicit shell
+/// integration override is also valid without a command.
+fn applyCommandOverrides(
+ config: *configpkg.Config,
+ command: ?configpkg.Command,
+ shell_integration: ?configpkg.Config.ShellIntegration,
+) Allocator.Error!void {
+ if (command) |value| {
+ config.command = try value.clone(config.arenaAlloc());
+
+ if (shell_integration) |integration| {
+ config.@"shell-integration" = integration;
+ } else if (config.@"shell-integration" != .none) {
+ config.@"shell-integration" = .detect;
+ }
+ } else if (shell_integration) |value| {
+ config.@"shell-integration" = value;
+ }
+}
+
+test "command and shell integration overrides" {
+ const testing = std.testing;
+
+ var config = try configpkg.Config.default(testing.allocator);
+ defer config.deinit();
+
+ config.@"shell-integration" = .nushell;
+ try applyCommandOverrides(&config, .{ .shell = "vim" }, null);
+ try testing.expectEqual(.detect, config.@"shell-integration");
+
+ config.@"shell-integration" = .none;
+ try applyCommandOverrides(&config, .{ .shell = "vim" }, null);
+ try testing.expectEqual(.none, config.@"shell-integration");
+
+ try applyCommandOverrides(&config, .{ .shell = "nu" }, .nushell);
+ try testing.expectEqual(.nushell, config.@"shell-integration");
+
+ try applyCommandOverrides(&config, .{ .shell = "vim" }, .none);
+ try testing.expectEqual(.none, config.@"shell-integration");
+
+ config.@"shell-integration" = .nushell;
+ try applyCommandOverrides(&config, null, null);
+ try testing.expectEqual(.nushell, config.@"shell-integration");
+
+ config.@"shell-integration" = .none;
+ try applyCommandOverrides(&config, null, .nushell);
+ try testing.expectEqual(.nushell, config.@"shell-integration");
+
+ try applyCommandOverrides(&config, null, .none);
+ try testing.expectEqual(.none, config.@"shell-integration");
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig
index d7dd41bc..3c103d59 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig
@@ -106,11 +106,11 @@ const SurfaceChildExitedBanner = extern struct {
return;
};
if (data.exit_code == 0) {
- banner.as(adw.Banner).setTitle(i18n._("Command succeeded"));
+ banner.as(adw.Banner).setTitle(i18n._("Command Succeeded"));
self.as(gtk.Widget).addCssClass("normal");
self.as(gtk.Widget).removeCssClass("abnormal");
} else {
- banner.as(adw.Banner).setTitle(i18n._("Command failed"));
+ banner.as(adw.Banner).setTitle(i18n._("Command Failed"));
self.as(gtk.Widget).removeCssClass("normal");
self.as(gtk.Widget).addCssClass("abnormal");
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
index 6ccb0a0d..ad9a4cc7 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig
@@ -89,7 +89,8 @@ pub const SurfaceScrolledWindow = extern struct {
const priv = self.private();
if (priv.config_binding) |binding| {
- binding.as(gobject.Object).unref();
+ binding.unbind();
+ binding.unref();
priv.config_binding = null;
}
@@ -168,18 +169,23 @@ pub const SurfaceScrolledWindow = extern struct {
// Unbind old config binding if it exists
if (priv.config_binding) |binding| {
- binding.as(gobject.Object).unref();
+ binding.unbind();
+ binding.unref();
priv.config_binding = null;
}
// Bind config from surface to our config property
if (priv.surface) |surface| {
- priv.config_binding = surface.as(gobject.Object).bindProperty(
+ const binding = surface.as(gobject.Object).bindProperty(
properties.config.name,
self.as(gobject.Object),
properties.config.name,
.{ .sync_create = true },
);
+ // Keep another ref, otherwise the binding would be freed and
+ // our pointer become stale if the surface gets finalized.
+ binding.ref();
+ priv.config_binding = binding;
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig
index 0c60c8cc..204be58f 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig
@@ -162,7 +162,7 @@ pub const Tab = extern struct {
/// The title of this tab. This is usually bound to the active surface.
title: ?[:0]const u8 = null,
- /// The manually overridden title from `promptTabTitle`.
+ /// The manually overridden title.
title_override: ?[:0]const u8 = null,
/// The tooltip of this tab. This is usually bound to the active surface.
@@ -189,6 +189,7 @@ pub const Tab = extern struct {
pub fn new(config: ?*Config, overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -212,6 +213,7 @@ pub const Tab = extern struct {
// Create our initial surface in the split tree.
priv.split_tree.newSplit(.right, null, .{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
}) catch |err| switch (err) {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig
index ac95ae4b..057e9dee 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig
@@ -220,10 +220,13 @@ pub const TitleDialog = extern struct {
pub const Target = enum(c_int) {
surface,
tab,
+ window,
+
pub fn title(self: Target) [*:0]const u8 {
return switch (self) {
.surface => i18n._("Change Terminal Title"),
.tab => i18n._("Change Tab Title"),
+ .window => i18n._("Change Window Title"),
};
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
index c34f2cb4..e2fe6403 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig
@@ -29,6 +29,7 @@ const Tab = @import("tab.zig").Tab;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const CommandPalette = @import("command_palette.zig").CommandPalette;
const WeakRef = @import("../weak_ref.zig").WeakRef;
+const TitleDialog = @import("title_dialog.zig").TitleDialog;
const log = std.log.scoped(.gtk_ghostty_window);
@@ -213,6 +214,19 @@ pub const Window = extern struct {
},
);
};
+
+ pub const @"title-override" = struct {
+ pub const name = "title-override";
+ const impl = gobject.ext.defineProperty(
+ name,
+ Self,
+ ?[:0]const u8,
+ .{
+ .default = null,
+ .accessor = C.privateStringFieldAccessor("title_override"),
+ },
+ );
+ };
};
const Private = struct {
@@ -259,6 +273,9 @@ pub const Window = extern struct {
/// setup by `setup-menu`.
context_menu_page: ?*adw.TabPage = null,
+ /// The manually overridden title.
+ title_override: ?[:0]const u8 = null,
+
// Template bindings
tab_overview: *adw.TabOverview,
tab_bar: *adw.TabBar,
@@ -365,6 +382,7 @@ pub const Window = extern struct {
.init("prompt-surface-title", actionPromptSurfaceTitle, null),
.init("prompt-tab-title", actionPromptTabTitle, null),
.init("prompt-context-tab-title", actionPromptContextTabTitle, null),
+ .init("prompt-window-title", actionPromptWindowTitle, null),
.init("ring-bell", actionRingBell, null),
.init("split-right", actionSplitRight, null),
.init("split-left", actionSplitLeft, null),
@@ -390,8 +408,20 @@ pub const Window = extern struct {
/// Create a new tab with the given parent. The tab will be inserted
/// at the position dictated by the `window-new-tab-position` config.
/// The new tab will be selected.
- pub fn newTab(self: *Self, parent_: ?*CoreSurface) void {
- _ = self.newTabPage(parent_, .tab, .none);
+ pub fn newTab(self: *Self, parent_: ?*CoreSurface, overrides: struct {
+ command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
+ working_directory: ?[:0]const u8 = null,
+ title: ?[:0]const u8 = null,
+
+ pub const none: @This() = .{};
+ }) void {
+ _ = self.newTabPage(parent_, .tab, .{
+ .command = overrides.command,
+ .shell_integration = overrides.shell_integration,
+ .working_directory = overrides.working_directory,
+ .title = overrides.title,
+ });
}
pub fn newTabForWindow(
@@ -399,6 +429,7 @@ pub const Window = extern struct {
parent_: ?*CoreSurface,
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -410,6 +441,7 @@ pub const Window = extern struct {
.window,
.{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
},
@@ -422,6 +454,7 @@ pub const Window = extern struct {
context: apprt.surface.NewSurfaceContext,
overrides: struct {
command: ?configpkg.Command = null,
+ shell_integration: ?configpkg.Config.ShellIntegration = null,
working_directory: ?[:0]const u8 = null,
title: ?[:0]const u8 = null,
@@ -436,6 +469,7 @@ pub const Window = extern struct {
priv.config,
.{
.command = overrides.command,
+ .shell_integration = overrides.shell_integration,
.working_directory = overrides.working_directory,
.title = overrides.title,
},
@@ -604,6 +638,29 @@ pub const Window = extern struct {
return tab_view.reorderPage(page, desired_pos) != 0;
}
+ pub fn moveTabToNewWindow(self: *Self, surface: *Surface) bool {
+ const tab_view = self.private().tab_view;
+ const tab = ext.getAncestor(
+ Tab,
+ surface.as(gtk.Widget),
+ ) orelse return false;
+ const page = tab_view.getPage(tab.as(gtk.Widget));
+
+ // Skip if this is the only tab in the existing window
+ if (tab_view.getNPages() <= 1) return false;
+
+ const app = Application.default();
+ const window = Window.new(app, .none);
+
+ tab_view.transferPage(
+ page,
+ window.private().tab_view,
+ 0,
+ );
+ window.as(gtk.Window).present();
+ return true;
+ }
+
pub fn toggleTabOverview(self: *Self) void {
const priv = self.private();
const tab_overview = priv.tab_overview;
@@ -1268,6 +1325,57 @@ pub const Window = extern struct {
});
}
+ pub fn setTitleOverride(self: *Self, title: ?[]const u8) void {
+ const priv: *Private = self.private();
+
+ if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
+ priv.title_override = null;
+
+ if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
+
+ self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
+ }
+
+ fn titleDialogSet(_: *TitleDialog, title_: [*:0]const u8, self: *Self) callconv(.c) void {
+ const title = std.mem.span(title_);
+ self.setTitleOverride(if (title.len == 0) null else title);
+ }
+
+ pub fn promptWindowTitle(self: *Self) void {
+ const priv: *Private = self.private();
+
+ var value = std.mem.zeroes(gobject.Value);
+ _ = value.init(gobject.ext.types.string);
+ defer value.unset();
+
+ self.as(gobject.Object).getProperty("title", &value);
+
+ const title_: ?[*:0]const u8 = value.getString();
+ const title: ?[:0]const u8 = if (title_) |v| std.mem.span(v) else null;
+
+ const dialog = TitleDialog.new(.window, priv.title_override orelse title);
+ _ = TitleDialog.signals.set.connect(
+ dialog,
+ *Self,
+ titleDialogSet,
+ self,
+ .{},
+ );
+
+ dialog.present(self.as(gtk.Widget));
+ }
+
+ fn closureTitle(
+ _: *Self,
+ _: ?*Config,
+ title_: ?[*:0]const u8,
+ title_override_: ?[*:0]const u8,
+ ) callconv(.c) ?[*:0]const u8 {
+ if (title_override_) |v| return glib.ext.dupeZ(u8, std.mem.span(v));
+ if (title_) |v| return glib.ext.dupeZ(u8, std.mem.span(v));
+ return glib.ext.dupeZ(u8, "Ghostty");
+ }
+
fn closureSubtitle(
_: *Self,
config_: ?*Config,
@@ -1296,7 +1404,7 @@ pub const Window = extern struct {
priv.handle_active_state_source = null;
}
- priv.command_palette.set(null);
+ priv.command_palette.deinit();
if (priv.config) |v| {
v.unref();
@@ -1320,6 +1428,7 @@ pub const Window = extern struct {
const priv = self.private();
priv.tab_bindings.unref();
priv.winproto.deinit();
+ if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
gobject.Object.virtual_methods.finalize.call(
Class.parent,
@@ -1374,11 +1483,33 @@ pub const Window = extern struct {
}
}
+ // Notify every displayed surface when the compositor changes the
+ // Wayland xdg_toplevel suspended state.
+ _ = gobject.Object.signals.notify.connect(
+ self.as(gtk.Window),
+ *Self,
+ propSuspended,
+ self,
+ .{ .detail = "suspended" },
+ );
+
// When we are realized we always setup our appearance since this
// calls some winproto functions.
self.syncAppearance();
}
+ fn propSuspended(
+ _: *gtk.Window,
+ _: *gobject.ParamSpec,
+ self: *Self,
+ ) callconv(.c) void {
+ const tab = self.getSelectedTab() orelse return;
+ const tree = tab.getSurfaceTree() orelse return;
+
+ var it = tree.iterator();
+ while (it.next()) |entry| entry.view.updateOcclusion();
+ }
+
fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void {
self.performBindingAction(.new_tab);
}
@@ -1954,6 +2085,14 @@ pub const Window = extern struct {
self.performBindingAction(.prompt_tab_title);
}
+ fn actionPromptWindowTitle(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *Window,
+ ) callconv(.c) void {
+ self.performBindingAction(.prompt_window_title);
+ }
+
fn actionSplitRight(
_: *gio.SimpleAction,
_: ?*glib.Variant,
@@ -2161,6 +2300,7 @@ pub const Window = extern struct {
properties.@"tabs-wide".impl,
properties.@"toolbar-style".impl,
properties.@"titlebar-style".impl,
+ properties.@"title-override".impl,
});
// Bindings
@@ -2191,6 +2331,7 @@ pub const Window = extern struct {
class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal);
class.bindTemplateCallback("notify_scale_factor", &propScaleFactor);
class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab);
+ class.bindTemplateCallback("computed_title", &closureTitle);
class.bindTemplateCallback("computed_subtitle", &closureSubtitle);
// Virtual methods
diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style.css b/vendor/libghostty-vt/src/apprt/gtk/css/style.css
index 9c0f115f..045d9906 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/css/style.css
+++ b/vendor/libghostty-vt/src/apprt/gtk/css/style.css
@@ -127,7 +127,7 @@ label.resize-overlay {
}
.surface .bell-overlay {
- border-color: rgba(58, 148, 74, 0.5);
+ border-color: rgba(53, 132, 228, 0.5);
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */
border-width: 3px;
@@ -145,6 +145,58 @@ label.resize-overlay {
outline-width: 1px;
}
/*
+ * Drag and drop overlay
+ */
+.drag-handle {
+ background-color: rgba(0, 0, 0, 0.15);
+ opacity: 0;
+ transition: 0.1s opacity;
+}
+.drag-handle:hover {
+ opacity: 1;
+}
+.drop-overlay.drop-left {
+ /* after GTK 4.16 is a requirement, switch to the following: */
+ /* background: linear-gradient(to left, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */
+ background: linear-gradient(
+ to left,
+ transparent,
+ 50%,
+ rgba(53, 132, 228, 0.2) 50%
+ );
+}
+.drop-overlay.drop-right {
+ /* after GTK 4.16 is a requirement, switch to the following: */
+ /* background: linear-gradient(to right, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */
+ background: linear-gradient(
+ to right,
+ transparent,
+ 50%,
+ rgba(53, 132, 228, 0.2) 50%
+ );
+}
+.drop-overlay.drop-top {
+ /* after GTK 4.16 is a requirement, switch to the following: */
+ /* background: linear-gradient(to top, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */
+ background: linear-gradient(
+ to top,
+ transparent,
+ 50%,
+ rgba(53, 132, 228, 0.2) 50%
+ );
+}
+.drop-overlay.drop-bottom {
+ /* after GTK 4.16 is a requirement, switch to the following: */
+ /* background: linear-gradient(to bottom, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */
+ background: linear-gradient(
+ to bottom,
+ transparent,
+ 50%,
+ rgba(53, 132, 228, 0.2) 50%
+ );
+}
+
+/*
* Command Palette
*/
.command-palette-search > image:first-child {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig
index 3232bc18..6ba9c678 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig
@@ -176,7 +176,11 @@ test "adding actions to an object" {
_ = addAsGroup(gtk.Box, box, "test", &actions);
}
- const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31));
+ const expected = expected: {
+ const rng_impl: std.Random.IoSource = .{ .io = testing.io };
+ const rng = rng_impl.interface();
+ break :expected rng.intRangeAtMost(i32, 1, std.math.maxInt(u31));
+ };
const parameter = glib.Variant.newInt32(expected);
try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0);
diff --git a/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig
index 71edb076..97e0a523 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig
@@ -3,18 +3,16 @@ const std = @import("std");
// Until the gobject bindings are built at the same time we are building
// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version
// macros match the version of `gtk4` that we are building/linking against.
-const c = @cImport({
- @cInclude("gtk/gtk.h");
-});
+const gtk_c = @import("gtk_c");
const gtk = @import("gtk");
const log = std.log.scoped(.gtk);
pub const comptime_version: std.SemanticVersion = .{
- .major = c.GTK_MAJOR_VERSION,
- .minor = c.GTK_MINOR_VERSION,
- .patch = c.GTK_MICRO_VERSION,
+ .major = gtk_c.GTK_MAJOR_VERSION,
+ .minor = gtk_c.GTK_MINOR_VERSION,
+ .patch = gtk_c.GTK_MICRO_VERSION,
};
pub fn getRuntimeVersion() std.SemanticVersion {
@@ -105,17 +103,17 @@ test "atLeast" {
const funs = &.{ atLeast, runtimeAtLeast };
inline for (funs) |fun| {
- try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION + 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
- try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
- try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
- try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1));
- try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION - 1, gtk_c.GTK_MICRO_VERSION + 1));
}
}
@@ -125,16 +123,16 @@ test "runtimeUntil" {
// This is an array in case we add a comptime variant.
const funs = &.{runtimeUntil};
inline for (funs) |fun| {
- try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
- try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
- try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
- try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION + 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1));
- try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1));
+ try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION - 1, gtk_c.GTK_MICRO_VERSION + 1));
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig
index fa4a6723..66904783 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig
@@ -6,6 +6,7 @@ const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
+const global = @import("../../../global.zig");
const apprt = @import("../../../apprt.zig");
const ApprtApp = @import("../App.zig");
@@ -28,10 +29,12 @@ payload_builder: *glib.VariantBuilder,
/// Used to build the parameters for the IPC.
parameters_builder: *glib.VariantBuilder,
+pub const InitError = Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors;
+
/// Initialize the helper.
-pub fn init(alloc: Allocator, target: apprt.ipc.Target, action: [:0]const u8) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!Self {
+pub fn init(alloc: Allocator, target: apprt.ipc.Target, action: [:0]const u8) InitError!Self {
var buf: [256]u8 = undefined;
- var stderr_writer = std.fs.File.stderr().writer(&buf);
+ var stderr_writer = std.Io.File.stderr().writer(global.io(), &buf);
const stderr = &stderr_writer.interface;
// Get the appropriate bus name and object path for contacting the
@@ -133,7 +136,7 @@ pub fn addParameter(self: *Self, variant: *glib.Variant) void {
/// should be done with this object other than call `deinit`.
pub fn send(self: *Self) (std.Io.Writer.Error || apprt.ipc.Errors)!void {
var buf: [256]u8 = undefined;
- var stderr_writer = std.fs.File.stderr().writer(&buf);
+ var stderr_writer = std.Io.File.stderr().writer(global.io(), &buf);
const stderr = &stderr_writer.interface;
// finish building the parameters
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig
new file mode 100644
index 00000000..c5e53895
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig
@@ -0,0 +1,71 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const glib = @import("glib");
+
+const apprt = @import("../../../apprt.zig");
+const DBus = @import("DBus.zig");
+
+// Use a D-Bus method call to open a new tab on GTK.
+// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI
+//
+// `ghostty +new-tab` is equivalent to the following command (on a release build):
+//
+// ```
+// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, [])>]' []
+// ```
+//
+// `ghostty +new-tab -e echo hello` would be equivalent to the following command (on a release build):
+//
+// ```
+// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, ["-e" "echo" "hello"])>]' []
+// ```
+pub fn newTab(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewTab) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
+ var dbus = try DBus.init(alloc, target, "new-tab");
+ defer dbus.deinit(alloc);
+
+ const tas_variant_type = glib.VariantType.new("(tas)");
+ defer tas_variant_type.free();
+
+ var parameter: glib.VariantBuilder = undefined;
+ parameter.init(tas_variant_type);
+ errdefer parameter.clear();
+
+ {
+ // Add the target surface ID to the parameter.
+ const t = glib.Variant.newUint64(value.surface_id);
+ parameter.addValue(t);
+ }
+
+ {
+ // If any arguments were specified on the command line, this value is an
+ // array of strings that contain the arguments. They will be sent to the
+ // main Ghostty instance and interpreted as CLI arguments.
+ const as_variant_type = glib.VariantType.new("as");
+ defer as_variant_type.free();
+
+ const s_variant_type = glib.VariantType.new("s");
+ defer s_variant_type.free();
+
+ var command: glib.VariantBuilder = undefined;
+ command.init(as_variant_type);
+ errdefer command.clear();
+
+ if (value.arguments) |arguments| {
+ for (arguments) |argument| {
+ const bytes = glib.Bytes.new(argument.ptr, argument.len + 1);
+ defer bytes.unref();
+ const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true));
+ command.addValue(string);
+ }
+ }
+
+ parameter.addValue(command.end());
+ }
+
+ dbus.addParameter(parameter.end());
+
+ try dbus.send();
+
+ return true;
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/key.zig b/vendor/libghostty-vt/src/apprt/gtk/key.zig
index 5f717e14..ff2064d4 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/key.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/key.zig
@@ -57,6 +57,16 @@ pub fn xdgShortcutFromTrigger(
return slice[0 .. slice.len - 1 :0];
}
+/// Returns the keysym (identical to a GDK keyval) for a trigger key,
+/// or null if the trigger has no keysym representation.
+pub fn keysymFromTrigger(trigger: input.Binding.Trigger) ?c_uint {
+ return switch (trigger.key) {
+ .physical => |k| keyvalFromKey(k),
+ .unicode => |cp| gdk.unicodeToKeyval(cp),
+ .catch_all => null,
+ };
+}
+
fn writeTriggerKey(
writer: *std.Io.Writer,
trigger: input.Binding.Trigger,
@@ -217,6 +227,94 @@ pub fn keyFromKeyval(keyval: c_uint) ?input.Key {
return null;
}
+/// Returns the logical key after applying any eligible XKB remapping.
+///
+/// Modifier keyvals that Ghostty doesn't represent must not fall back to the
+/// physical key. XKB can turn any physical key into a modifier, and using the
+/// original key in that case would encode an unrelated key press.
+pub fn remapKey(
+ physical_key: input.Key,
+ keyval: c_uint,
+ is_modifier: bool,
+) input.Key {
+ if (keyFromKeyval(keyval)) |remapped| {
+ if (physical_key.shouldBeRemappable() or remapped.shouldBeRemappable())
+ return remapped;
+ }
+
+ if (is_modifier or isModifierKeyval(keyval)) return .unidentified;
+ return physical_key;
+}
+
+/// Returns whether a keyval has modifier semantics that GDK may not expose
+/// through `gdk_key_event_is_modifier`.
+///
+/// In particular, XKB can report ISO level and group modifiers as keyvals
+/// such as `Mode_switch` while GDK reports that the event is not a modifier.
+fn isModifierKeyval(keyval: c_uint) bool {
+ return switch (keyval) {
+ gdk.KEY_Shift_L,
+ gdk.KEY_Shift_R,
+ gdk.KEY_Shift_Lock,
+ gdk.KEY_Control_L,
+ gdk.KEY_Control_R,
+ gdk.KEY_Caps_Lock,
+ gdk.KEY_Meta_L,
+ gdk.KEY_Meta_R,
+ gdk.KEY_Alt_L,
+ gdk.KEY_Alt_R,
+ gdk.KEY_Super_L,
+ gdk.KEY_Super_R,
+ gdk.KEY_Hyper_L,
+ gdk.KEY_Hyper_R,
+ gdk.KEY_Num_Lock,
+ gdk.KEY_Mode_switch,
+ gdk.KEY_ISO_Lock,
+ gdk.KEY_ISO_Level2_Latch,
+ gdk.KEY_ISO_Level3_Shift,
+ gdk.KEY_ISO_Level3_Latch,
+ gdk.KEY_ISO_Level3_Lock,
+ gdk.KEY_ISO_Level5_Shift,
+ gdk.KEY_ISO_Level5_Latch,
+ gdk.KEY_ISO_Level5_Lock,
+ gdk.KEY_ISO_Group_Latch,
+ gdk.KEY_ISO_Group_Lock,
+ => true,
+
+ else => false,
+ };
+}
+
+test "remap key" {
+ const testing = std.testing;
+
+ // XKB remaps between non-writing-system keys are honored.
+ try testing.expectEqual(
+ input.Key.escape,
+ remapKey(.caps_lock, gdk.KEY_Escape, false),
+ );
+
+ // Writing-system keys retain their physical identity.
+ try testing.expectEqual(
+ input.Key.key_a,
+ remapKey(.key_a, gdk.KEY_b, false),
+ );
+
+ // GDK doesn't identify Mode_switch as a modifier, but it must not fall
+ // back to the physical key.
+ try testing.expectEqual(
+ input.Key.unidentified,
+ remapKey(.delete, gdk.KEY_Mode_switch, false),
+ );
+
+ // GDK's modifier flag also covers custom modifier actions whose keyval
+ // Ghostty doesn't represent.
+ try testing.expectEqual(
+ input.Key.unidentified,
+ remapKey(.delete, gdk.KEY_VoidSymbol, true),
+ );
+}
+
/// Returns a keyval from an input key or null if we don't have a mapping.
pub fn keyvalFromKey(key: input.Key) ?c_uint {
switch (key) {
@@ -327,6 +425,28 @@ test "xdgShortcutFromTrigger" {
})).?);
}
+test "keysymFromTrigger" {
+ const testing = std.testing;
+
+ // Unicode keys use the keysym numbering (latin-1 maps directly).
+ try testing.expectEqual(@as(?c_uint, 'q'), keysymFromTrigger(.{
+ .mods = .{ .super = true },
+ .key = .{ .unicode = 'q' },
+ }));
+
+ // Physical keys map through our keymap.
+ try testing.expectEqual(@as(?c_uint, gdk.KEY_a), keysymFromTrigger(.{
+ .mods = .{},
+ .key = .{ .physical = .key_a },
+ }));
+
+ // catch_all has no keysym representation.
+ try testing.expectEqual(@as(?c_uint, null), keysymFromTrigger(.{
+ .mods = .{},
+ .key = .catch_all,
+ }));
+}
+
test "labelFromTrigger" {
const testing = std.testing;
diff --git a/vendor/libghostty-vt/src/apprt/gtk/media.zig b/vendor/libghostty-vt/src/apprt/gtk/media.zig
index 7883bf37..3e6a19c3 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/media.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/media.zig
@@ -7,10 +7,15 @@ const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
+const global = @import("../../global.zig");
pub fn fromFilename(path: [:0]const u8) ?*gtk.MediaFile {
assert(std.fs.path.isAbsolute(path));
- std.fs.accessAbsolute(path, .{ .mode = .read_only }) catch |err| {
+ std.Io.Dir.accessAbsolute(
+ global.io(),
+ path,
+ .{ .read = true },
+ ) catch |err| {
log.warn("unable to access {s}: {t}", .{ path, err });
return null;
};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal.zig b/vendor/libghostty-vt/src/apprt/gtk/portal.zig
index 3e51042d..11c3356d 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/portal.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/portal.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const gio = @import("gio");
+const global = @import("../../global.zig");
const Allocator = std.mem.Allocator;
@@ -11,7 +12,8 @@ const token_format = std.fmt.comptimePrint("{{x:0>{}}}", .{token_hex_len});
/// Generate a token suitable for use in requests to the XDG Desktop Portal
pub fn generateToken() usize {
- return std.crypto.random.int(usize);
+ const rng_impl: std.Random.IoSource = .{ .io = global.io() };
+ return rng_impl.interface().int(usize);
}
/// Format a request token consistently for use in portal object paths and payloads.
diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig
index 97aa013e..40191482 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig
@@ -9,6 +9,7 @@ const assert = std.debug.assert;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
+const global = @import("../../../global.zig");
const App = @import("../App.zig");
const portal = @import("../portal.zig");
@@ -23,7 +24,7 @@ app: *App,
dbus: ?*gio.DBusConnection = null,
/// Mutex to protect modification of the entries map or the cleanup timer.
-mutex: std.Thread.Mutex = .{},
+mutex: std.Io.Mutex = .init,
/// Map to store data about any in-flight calls to the portal.
entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty,
@@ -78,7 +79,7 @@ const RequestData = struct {
/// Data about any in-flight calls to the portal.
pub const Entry = struct {
/// When the request started.
- start: std.time.Instant,
+ start: std.Io.Timestamp,
/// A token used by the portal to identify requests and responses. The
/// actual format of the token does not really matter as long as it can be
/// used as part of a D-Bus object path. `usize` was chosen since it's easy
@@ -123,8 +124,8 @@ pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void {
pub fn deinit(self: *OpenURI) void {
const alloc = self.app.app.allocator();
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
if (!self.alive) return;
self.alive = false;
@@ -157,8 +158,8 @@ pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Er
alloc.destroy(request);
}
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
// Create an entry that is used to track the results of the D-Bus method
// call.
@@ -166,7 +167,7 @@ pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Er
const entry = try alloc.create(Entry);
errdefer alloc.destroy(entry);
entry.* = .{
- .start = std.time.Instant.now() catch return error.TimerUnavailable,
+ .start = std.Io.Timestamp.now(global.io(), .awake),
.token = token,
.kind = value.kind,
.uri = try alloc.dupeZ(u8, value.url),
@@ -235,8 +236,8 @@ fn destroyEntry(alloc: Allocator, entry: *Entry) void {
}
fn failRequest(self: *OpenURI, token: usize) ?*Entry {
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
if (!self.alive) return null;
@@ -422,8 +423,8 @@ fn requestCallback(
return;
}
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
if (!self.alive) return;
@@ -461,8 +462,8 @@ fn responseReceived(
return;
};
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
if (!self.alive) return;
@@ -534,22 +535,18 @@ fn cleanup(ud: ?*anyopaque) callconv(.c) c_int {
const alloc = self.app.app.allocator();
- self.mutex.lock();
- defer self.mutex.unlock();
+ self.mutex.lockUncancelable(global.io());
+ defer self.mutex.unlock(global.io());
self.cleanup_timer = null;
if (!self.alive) return @intFromBool(glib.SOURCE_REMOVE);
- const now = std.time.Instant.now() catch {
- // `now()` should never fail, but if it does, don't crash, just return.
- // This might cause a small memory leak in rare circumstances but it
- // should get cleaned up the next time a URL is clicked.
- return @intFromBool(glib.SOURCE_REMOVE);
- };
-
loop: while (true) {
for (self.entries.entries.items(.value)) |entry| {
- if (now.since(entry.start) > cleanup_timeout * std.time.ns_per_s) {
+ if (entry.start.untilNow(
+ global.io(),
+ .awake,
+ ).toSeconds() > cleanup_timeout) {
log.warn("open uri request timed out token={x}", .{entry.token});
self.unsubscribeFromResponse(entry);
_ = self.entries.swapRemove(entry.token);
diff --git a/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig
index ff021950..d9b576fa 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const gio = @import("gio");
const glib = @import("glib");
+const global = @import("../../global.zig");
const log = std.log.scoped(.gtk_post_fork);
@@ -85,12 +86,10 @@ pub fn postFork(cmd: *Command) Command.PostForkError!void {
return;
};
- const start = std.time.Instant.now() catch unreachable;
+ const start: std.Io.Timestamp = .now(global.io(), .awake);
loop: while (true) {
- const now = std.time.Instant.now() catch unreachable;
-
- if (now.since(start) > 250 * std.time.ns_per_ms) {
+ if (start.untilNow(global.io(), .awake).toMilliseconds() > 250) {
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
log.err("transition to new transient systemd scope {s} took too long", .{expected_cgroup});
return error.PostForkError;
@@ -116,6 +115,6 @@ pub fn postFork(cmd: *Command) Command.PostForkError!void {
}
}
- std.Thread.sleep(25 * std.time.ns_per_ms);
+ std.Io.sleep(global.io(), .fromMilliseconds(25), .awake) catch unreachable;
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig
index 6f6a9ed5..a4eda462 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig
@@ -3,6 +3,7 @@ const std = @import("std");
const log = std.log.scoped(.gtk_pre_exec);
const configpkg = @import("../../config.zig");
+const global = @import("../../global.zig");
const internal_os = @import("../../os/main.zig");
const Command = @import("../../Command.zig");
@@ -47,12 +48,10 @@ pub fn preExec(cmd: *Command) ?u8 {
var expected_cgroup_buf: [256]u8 = undefined;
const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid);
- const start = std.time.Instant.now() catch unreachable;
+ const start: std.Io.Timestamp = .now(global.io(), .awake);
while (true) {
- const now = std.time.Instant.now() catch unreachable;
-
- if (now.since(start) > 250 * std.time.ns_per_ms) {
+ if (start.untilNow(global.io(), .awake).toMilliseconds() > 250) {
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
log.err("transition to new transient systemd scope took too long", .{});
return 127;
@@ -74,7 +73,7 @@ pub fn preExec(cmd: *Command) ?u8 {
if (std.mem.eql(u8, current_cgroup, expected_cgroup)) return null;
}
- std.Thread.sleep(25 * std.time.ns_per_ms);
+ std.Io.sleep(global.io(), .fromMilliseconds(25), .awake) catch unreachable;
}
return null;
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp
index 845909eb..77c71ede 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp
@@ -4,7 +4,9 @@ using Adw 1;
template $GhosttyConfigErrorsDialog: $GhosttyDialog {
heading: _("Configuration Errors");
- body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors.");
+ body: _(
+ "One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."
+ );
responses [
ignore: _("Ignore"),
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp
index 6523d414..8ab2e310 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp
@@ -46,7 +46,12 @@ template $GhosttySearchOverlay: Adw.Bin {
"dim-label",
]
- label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as <string>;
+ label: bind $match_label_closure(
+ template.has-search-selected,
+ template.search-selected,
+ template.has-search-total,
+ template.search-total
+ ) as <string>;
width-chars: 6;
xalign: 1.0;
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
index 2ae0a347..010798ae 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp
@@ -1,4 +1,5 @@
using Gtk 4.0;
+using Gdk 4.0;
using Adw 1;
Adw.StatusPage error_page {
@@ -96,7 +97,9 @@ Overlay terminal_page {
// TODO: the tooltip doesn't actually work, but keep it here for now so
// that we can get the tooltip text translated.
has-tooltip: true;
- tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.");
+ tooltip-text: _(
+ "This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."
+ );
halign: end;
valign: start;
spacing: 6;
@@ -205,7 +208,11 @@ Overlay terminal_page {
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface
Revealer {
- reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as <bool>;
+ reveal-child: bind $should_unfocused_split_be_shown(
+ search_overlay.active,
+ template.focused,
+ template.is-split
+ ) as <bool>;
transition-duration: 0;
// This is all necessary so that the Revealer itself doesn't override
// any input events from the other overlays. Namely, if you don't have
@@ -221,10 +228,57 @@ Overlay terminal_page {
}
}
+ [overlay]
+ CenterBox drag_handle {
+ halign: fill;
+ valign: start;
+ visible: bind $should_drag_handle_be_shown(template.config, template.is-split) as <bool>;
+
+ styles [
+ "drag-handle",
+ ]
+
+ [center]
+ Image {
+ icon-name: "view-more-horizontal-symbolic";
+ }
+
+ DragSource surface_drag_source {
+ actions: move;
+ prepare => $surface_drag_prepare();
+ drag-begin => $surface_drag_begin();
+ }
+ }
+
+ [overlay]
+ DrawingArea drop_overlay {
+ halign: fill;
+ valign: fill;
+ can-focus: false;
+ can-target: false;
+ focusable: false;
+
+ styles [
+ "drop-overlay",
+ ]
+ }
+
DropTarget drop_target {
drop => $drop();
actions: copy | move;
}
+
+ DropTarget surface_drop_target {
+ // Enabling preload is required since that lets us know
+ // which surface is currently being dragged, and to turn off
+ // the drop overlay if the source and target are the same
+ preload: true;
+ leave => $surface_drop_leave();
+ motion => $surface_drop_motion();
+ drop => $surface_drop();
+ notify::value => $notify_drop_value();
+ actions: move;
+ }
}
template $GhosttySurface: Adw.Bin {
@@ -349,6 +403,11 @@ menu context_menu_model {
label: _("Window");
item {
+ label: _("Change Window Title…");
+ action: "win.prompt-window-title";
+ }
+
+ item {
label: _("New Window");
action: "win.new-window";
}
@@ -365,8 +424,15 @@ menu context_menu_model {
label: _("Config");
item {
- label: _("Open Configuration");
+ label: _("Open Configuration in OS Editor");
+ action: "app.open-config";
+ target: "os-open";
+ }
+
+ item {
+ label: _("Open Configuration in New Window");
action: "app.open-config";
+ target: "new-window";
}
item {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp
index 55f2e7ef..21a30bec 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp
@@ -8,7 +8,14 @@ template $GhosttyTab: Box {
orientation: vertical;
hexpand: true;
vexpand: true;
- title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
+ title: bind $computed_title(
+ template.config,
+ split_tree.active-surface as <$GhosttySurface>.title,
+ split_tree.active-surface as <$GhosttySurface>.title-override,
+ template.title-override,
+ split_tree.is-zoomed,
+ split_tree.active-surface as <$GhosttySurface>.bell-ringing
+ ) as <string>;
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
$GhosttySplitTree split_tree {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp
index b66a9309..32d1bf37 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp
+++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp
@@ -40,14 +40,17 @@ template $GhosttyWindow: Adw.ApplicationWindow {
visible: bind template.headerbar-visible;
title-widget: Adw.WindowTitle {
- title: bind template.title;
+ title: bind $computed_title(template.config, template.title, template.title-override) as <string>;
// Blueprint auto-formatter won't let me split this into multiple
// lines. Let me explain myself. All parameters to a closure are used
// as notifications to recompute the value of the closure. All
// elements of a property chain are also subscribed to for changes.
// This one long, ugly line saves us from manually building up this
// massive notify chain in code.
- subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as <string>;
+ subtitle: bind $computed_subtitle(
+ template.config,
+ tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd
+ ) as <string>;
};
[start]
@@ -209,6 +212,11 @@ menu main_menu {
section {
item {
+ label: _("Change Window Title…");
+ action: "win.prompt-window-title";
+ }
+
+ item {
label: _("New Window");
action: "win.new-window";
}
@@ -291,14 +299,25 @@ menu main_menu {
action: "win.toggle-inspector";
}
- item {
- label: _("Open Configuration");
- action: "app.open-config";
- }
+ submenu {
+ label: _("Config");
- item {
- label: _("Reload Configuration");
- action: "app.reload-config";
+ item {
+ label: _("Open Configuration in OS Editor");
+ action: "app.open-config";
+ target: "os-open";
+ }
+
+ item {
+ label: _("Open Configuration in New Window");
+ action: "app.open-config";
+ target: "new-window";
+ }
+
+ item {
+ label: _("Reload Configuration");
+ action: "app.reload-config";
+ }
}
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig
index f689e45f..73cc6e51 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig
@@ -22,6 +22,23 @@ pub fn WeakRef(comptime T: type) type {
}
}
+ /// Release this weak reference.
+ ///
+ /// You MUST call this before the memory holding this struct is freed,
+ /// which in practice means from the owner's `dispose`. The target keeps
+ /// a pointer to this `GWeakRef` so that it can clear it when the target
+ /// is finalized; if this memory is gone by then, the target walks into
+ /// whatever now occupies it. That is an invalid read at best, and can
+ /// hang: the target takes a lock inside each registered weak ref, and
+ /// reused memory with the low bit set is a lock nothing will release.
+ ///
+ /// `set(null)` also unregisters and remains valid. This exists so the
+ /// requirement has a name at the use site rather than looking like an
+ /// ordinary assignment.
+ pub fn deinit(self: *Self) void {
+ self.ref.clear();
+ }
+
/// Get a strong reference to the object, or null if the object
/// has been finalized. This increases the reference count by one.
pub fn get(self: *Self) ?*T {
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig
index d409d6c2..abf46d99 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/winproto.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig
@@ -8,6 +8,7 @@ const Config = @import("../../config.zig").Config;
const input = @import("../../input.zig");
const key = @import("key.zig");
const ApprtWindow = @import("class/window.zig").Window;
+const GlobalShortcuts = @import("class/global_shortcuts.zig").GlobalShortcuts;
pub const noop = @import("winproto/noop.zig");
pub const x11 = @import("winproto/x11.zig");
@@ -78,6 +79,26 @@ pub const App = union(Protocol) {
inline else => |*v| try v.initQuickTerminal(apprt_window),
}
}
+
+ /// Bind all global keybinds through a mechanism native to the
+ /// windowing protocol. Returns false if there is none; the caller
+ /// should then fall back to the XDG desktop portal.
+ pub fn bindGlobalShortcuts(
+ self: *App,
+ shortcuts: *GlobalShortcuts,
+ config: *const Config,
+ ) bool {
+ return switch (self.*) {
+ inline else => |*v| v.bindGlobalShortcuts(shortcuts, config),
+ };
+ }
+
+ /// Unbind all global keybinds bound via bindGlobalShortcuts.
+ pub fn clearGlobalShortcuts(self: *App) void {
+ switch (self.*) {
+ inline else => |*v| v.clearGlobalShortcuts(),
+ }
+ }
};
/// Per-Window state for the underlying windowing protocol.
@@ -141,7 +162,7 @@ pub const Window = union(Protocol) {
};
}
- pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void {
switch (self.*) {
inline else => |*v| try v.addSubprocessEnv(env),
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig
index 950ee0f3..e5f59dc4 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig
@@ -6,6 +6,7 @@ const gdk = @import("gdk");
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
const ApprtWindow = @import("../class/window.zig").Window;
+const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts;
const log = std.log.scoped(.winproto_noop);
@@ -35,6 +36,16 @@ pub const App = struct {
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
+
+ pub fn bindGlobalShortcuts(
+ _: *App,
+ _: *GlobalShortcuts,
+ _: *const Config,
+ ) bool {
+ return false;
+ }
+
+ pub fn clearGlobalShortcuts(_: *App) void {}
};
pub const Window = struct {
@@ -67,7 +78,7 @@ pub const Window = struct {
return true;
}
- pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {}
+ pub fn addSubprocessEnv(_: *Window, _: *std.process.Environ.Map) !void {}
pub fn setUrgent(_: *Window, _: bool) !void {}
};
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig
index 12c7fb8a..81481514 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig
@@ -17,9 +17,12 @@ const xdg = wayland.client.xdg;
const Config = @import("../../../config.zig").Config;
const Globals = @import("wayland/Globals.zig");
+const Hotkeys = @import("wayland/Hotkeys.zig");
const input = @import("../../../input.zig");
const ApprtWindow = @import("../class/window.zig").Window;
+const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts;
const BlurRegion = @import("BlurRegion.zig");
+const gtk_version = @import("../gtk_version.zig");
const log = std.log.scoped(.winproto_wayland);
@@ -27,6 +30,7 @@ const log = std.log.scoped(.winproto_wayland);
pub const App = struct {
display: *wl.Display,
globals: *Globals,
+ hotkeys: Hotkeys,
pub fn init(
alloc: Allocator,
@@ -35,7 +39,6 @@ pub const App = struct {
config: *const Config,
) !?App {
_ = config;
- _ = app_id;
const gdk_wayland_display = gobject.ext.cast(
gdk_wayland.WaylandDisplay,
@@ -49,16 +52,35 @@ pub const App = struct {
const globals: *Globals = try .init(alloc, display);
errdefer globals.deinit();
+ var hotkeys: Hotkeys = try .init(alloc, app_id);
+ errdefer hotkeys.deinit();
+
return .{
.display = display,
.globals = globals,
+ .hotkeys = hotkeys,
};
}
pub fn deinit(self: *App) void {
+ self.hotkeys.deinit();
self.globals.deinit();
}
+ pub fn bindGlobalShortcuts(
+ self: *App,
+ shortcuts: *GlobalShortcuts,
+ config: *const Config,
+ ) bool {
+ const manager = self.globals.get(.vicinae_hotkey_manager) orelse return false;
+ self.hotkeys.bind(manager, shortcuts, config);
+ return true;
+ }
+
+ pub fn clearGlobalShortcuts(self: *App) void {
+ self.hotkeys.clear();
+ }
+
pub fn eventMods(
_: *App,
_: ?*gdk.Device,
@@ -153,6 +175,12 @@ pub const Window = struct {
};
const bg_effect: ?*ext.BackgroundEffectSurfaceV1 = bg: {
+ if (gtk_version.runtimeAtLeast(4, 23, 3)) {
+ // GTK 4.23.3 added an official way to do background blur,
+ // so we don't need to do anything on our own.
+ break :bg null;
+ }
+
const mgr = app.globals.get(.ext_background_effect) orelse
break :bg null;
@@ -225,7 +253,7 @@ pub const Window = struct {
};
}
- pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void {
_ = self;
_ = env;
}
@@ -278,7 +306,7 @@ pub const Window = struct {
}
const wl_region = try compositor.createRegion();
- errdefer if (wl_region) |r| r.destroy();
+ defer wl_region.destroy();
for (region.slices.items) |s| wl_region.add(
@intCast(s.x),
@intCast(s.y),
@@ -287,6 +315,7 @@ pub const Window = struct {
);
bg.setBlurRegion(wl_region);
+ self.blur_region.deinit(self.globals.alloc);
self.blur_region = region;
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig
index 83052cbe..bb4a48c9 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig
@@ -8,6 +8,7 @@ const wl = wayland.client.wl;
const ext = wayland.client.ext;
const kde = wayland.client.kde;
const org = wayland.client.org;
+const vicinae = wayland.client.vicinae;
const xdg = wayland.client.xdg;
const log = std.log.scoped(.winproto_wayland_globals);
@@ -32,6 +33,7 @@ pub const Tag = enum {
kde_decoration_manager,
kde_slide_manager,
kde_output_order,
+ vicinae_hotkey_manager,
xdg_activation,
fn Type(comptime self: Tag) type {
@@ -41,6 +43,7 @@ pub const Tag = enum {
.kde_decoration_manager => org.KdeKwinServerDecorationManager,
.kde_slide_manager => org.KdeKwinSlideManager,
.kde_output_order => kde.OutputOrderV1,
+ .vicinae_hotkey_manager => vicinae.HotkeyManagerV1,
.xdg_activation => xdg.ActivationV1,
};
}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig
new file mode 100644
index 00000000..91e54646
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig
@@ -0,0 +1,165 @@
+//! Global shortcuts backed by the vicinae-hotkey Wayland protocol.
+const Hotkeys = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const wayland = @import("wayland");
+const vicinae = wayland.client.vicinae;
+
+const Config = @import("../../../../config.zig").Config;
+const Binding = @import("../../../../input.zig").Binding;
+const key = @import("../../key.zig");
+const GlobalShortcuts = @import("../../class/global_shortcuts.zig").GlobalShortcuts;
+
+const log = std.log.scoped(.winproto_wayland_hotkeys);
+
+alloc: Allocator,
+app_id: [:0]const u8,
+entries: std.ArrayList(Entry) = .empty,
+
+const Entry = struct {
+ /// Null once the binding was denied or revoked.
+ hotkey: ?*vicinae.HotkeyV1,
+ trigger: Binding.Trigger,
+ action: Binding.Action,
+ shortcuts: *GlobalShortcuts,
+
+ fn deinit(self: *Entry) void {
+ if (self.hotkey) |hotkey| hotkey.destroy();
+ }
+
+ fn bind(
+ self: *Entry,
+ manager: *vicinae.HotkeyManagerV1,
+ app_id: [:0]const u8,
+ ) !void {
+ // The only time this will return an error is when
+ // `trigger.key` is `catch_all`, which is guarded
+ // in the public `bind` function
+ const keysym = key.keysymFromTrigger(self.trigger) orelse unreachable;
+
+ var desc_buf: [256]u8 = undefined;
+ const desc = std.fmt.bufPrintZ(&desc_buf, "{f}", .{self.action}) catch "";
+
+ const hotkey = try manager.bind(
+ keysym,
+ .{
+ .shift = self.trigger.mods.shift,
+ .ctrl = self.trigger.mods.ctrl,
+ .alt = self.trigger.mods.alt,
+ .super = self.trigger.mods.super,
+ },
+ null,
+ app_id.ptr,
+ desc.ptr,
+ );
+ errdefer hotkey.destroy();
+ hotkey.setListener(*Entry, hotkeyListener, self);
+ self.hotkey = hotkey;
+ }
+
+ fn fail(self: *Entry, message: [*:0]const u8, revoked: bool) void {
+ self.hotkey.?.destroy();
+ self.hotkey = null;
+
+ self.shortcuts.emitBindFailed(&.{
+ .trigger = self.trigger,
+ .action = self.action,
+ .message = message,
+ .revoked = revoked,
+ });
+ }
+};
+
+pub fn init(alloc: Allocator, app_id: [:0]const u8) Allocator.Error!Hotkeys {
+ return .{
+ .alloc = alloc,
+ .app_id = try alloc.dupeZ(u8, app_id),
+ };
+}
+
+/// Must leave the entries in a valid empty state: clear may still be
+/// called after deinit during application teardown.
+pub fn deinit(self: *Hotkeys) void {
+ for (self.entries.items) |*entry| entry.deinit();
+ self.entries.clearAndFree(self.alloc);
+ self.alloc.free(self.app_id);
+}
+
+pub fn clear(self: *Hotkeys) void {
+ for (self.entries.items) |*entry| entry.deinit();
+ self.entries.clearRetainingCapacity();
+}
+
+pub fn bind(
+ self: *Hotkeys,
+ manager: *vicinae.HotkeyManagerV1,
+ shortcuts: *GlobalShortcuts,
+ config: *const Config,
+) void {
+ self.clear();
+
+ var it = config.keybind.set.bindings.iterator();
+ while (it.next()) |entry| {
+ const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
+ .leader => continue,
+ inline .leaf, .leaf_chained => |leaf| leaf.generic(),
+ };
+ if (!leaf.flags.global) continue;
+ // Catch all global keybinds don't really make sense
+ if (entry.key_ptr.key == .catch_all) continue;
+
+ // Only single-action global keybinds are supported, as in the
+ // portal implementation.
+ const actions = leaf.actionsSlice();
+ if (actions.len != 1) continue;
+
+ self.entries.append(self.alloc, .{
+ .hotkey = null,
+ .trigger = entry.key_ptr.*,
+ .action = actions[0],
+ .shortcuts = shortcuts,
+ }) catch {};
+ }
+
+ for (self.entries.items) |*entry| {
+ entry.bind(manager, self.app_id) catch |err| {
+ log.warn("failed to request hotkey trigger={f} err={}", .{
+ entry.trigger,
+ err,
+ });
+ };
+ }
+}
+
+fn hotkeyListener(
+ _: *vicinae.HotkeyV1,
+ event: vicinae.HotkeyV1.Event,
+ entry: *Entry,
+) void {
+ switch (event) {
+ .bound => log.debug("hotkey bound action={f}", .{entry.action}),
+
+ .denied => |v| {
+ log.warn("hotkey denied action={f} reason={} message={s}", .{
+ entry.action,
+ v.reason,
+ v.message,
+ });
+ entry.fail(v.message, false);
+ },
+
+ .revoked => |v| {
+ log.warn("hotkey revoked action={f} reason={} message={s}", .{
+ entry.action,
+ v.reason,
+ v.message,
+ });
+ entry.fail(v.message, true);
+ },
+
+ .pressed => entry.shortcuts.emitTrigger(&entry.action),
+ .released => {},
+ }
+}
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml
new file mode 100644
index 00000000..30f97394
--- /dev/null
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml
@@ -0,0 +1,305 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="vicinae_hotkey_v1">
+ <copyright>
+ Copyright © 2026 Aurelien Brabant
+
+ Permission is hereby granted, free of charge, to any person obtaining a
+ copy of this software and associated documentation files (the "Software"),
+ to deal in the Software without restriction, including without limitation
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ and/or sell copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice (including the next
+ paragraph) shall be included in all copies or substantial portions of the
+ Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE.
+ </copyright>
+
+ <description summary="client-managed global hotkeys">
+ This protocol lets a client choose, and freely reconfigure, its own
+ global hotkeys: it requests a specific key combination and the compositor
+ accepts it or rejects it with a reason. The compositor remains the eventual
+ arbiter (it may deny a request, revoke a binding at any time, or apply
+ whatever policy it likes), but within that the application manages its own
+ bindings, including changing them at runtime, with no out-of-band user
+ configuration and no persisted compositor state.
+
+ A hotkey is a key combination that fires regardless of which surface holds
+ keyboard focus. This is unrelated to keyboard-shortcuts-inhibit, which lets a
+ focused client suppress the compositor's own shortcuts.
+
+ Whether a combination is exclusive is compositor policy. A compositor MAY
+ treat combinations as exclusive and reject a clashing request with
+ "already_bound", or MAY grant the same combination to more than one binding
+ (each bound hotkey then receives the events), as some platforms do. Clients
+ must cope with either.
+
+ It is intentionally a thin mechanism. It does NOT define which combinations
+ are acceptable: that is policy and belongs to the compositor, expressed
+ through the accept/deny channel. It does NOT persist hotkeys: a binding lives
+ only as long as its vicinae_hotkey_v1 object and the client connection; there is
+ no configure or storage step, a binding never outlives the client that created
+ it, and reconfiguring is simply destroying a hotkey and binding the new
+ combination.
+
+ Warning! The protocol described in this file is currently in the testing
+ phase. Backward incompatible changes may be added together with the
+ corresponding interface version bump. Backward compatible changes are added
+ by bumping the interface version.
+
+ Security considerations:
+
+ A global hotkey lets an unfocused client observe that a specific key
+ combination was pressed. Compositors are the security boundary and SHOULD
+ apply policy when deciding whether to accept a request. In particular,
+ implementations SHOULD reject combinations that do not include at least one
+ non-latching modifier among Ctrl, Alt or Super, unless the trigger is a
+ function key, because unmodified (and Shift-only) keys carry ordinary text
+ entry and grabbing them turns this protocol into a keylogger.
+
+ The protocol's one hard guarantee is containment: a compositor MUST NOT
+ deliver events for a combination the client did not successfully bind, and
+ never forwards the raw key stream, so a client learns nothing about input it
+ did not explicitly, and successfully, bind. Beyond that, a compositor MAY
+ apply any further policy it sees fit (for example prompting the user,
+ restricting which clients may bind, or limiting how many bindings a client may
+ hold) and MAY revoke a binding at any time via the "revoked" event, including
+ under user control.
+
+ These are recommendations, not wire requirements: a combination the
+ compositor disallows is simply reported through the "denied" event with the
+ "not_permitted" reason, so applications can rely on a uniform rejection path
+ regardless of each compositor's policy.
+ </description>
+
+ <interface name="vicinae_hotkey_manager_v1" version="1">
+ <description summary="global hotkey factory">
+ This interface is the entry point of the protocol. It is used to request
+ global hotkeys.
+ </description>
+
+ <enum name="modifiers" bitfield="true">
+ <description summary="modifier keys required by a hotkey">
+ A bitmask of the modifiers that must be held for the hotkey to fire.
+
+ These are fixed, keymap-independent semantic bits naming the standard
+ modifiers, suitable for expressing a binding, unlike wl_keyboard.modifiers,
+ which carries opaque, keymap-derived masks for runtime state. A compositor
+ matches each bit against the corresponding modifier in its keyboard state
+ as the keymap defines it; for the xkb_v1 keymap format that is shift ->
+ XKB_MOD_NAME_SHIFT, ctrl -> XKB_MOD_NAME_CTRL, alt -> XKB_MOD_NAME_ALT
+ ("Mod1"), super -> XKB_MOD_NAME_LOGO ("Mod4").
+
+ Lock modifiers (Caps Lock, Num Lock) are never part of a binding and are
+ ignored when matching.
+ </description>
+ <entry name="shift" value="1" summary="the Shift modifier"/>
+ <entry name="ctrl" value="2" summary="the Control modifier"/>
+ <entry name="alt" value="4" summary="the Alt modifier"/>
+ <entry name="super" value="8" summary="the Super/Logo modifier"/>
+ </enum>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the manager">
+ Destroy the manager object. Hotkey objects created through this manager
+ are unaffected and remain valid; their bindings persist until they are
+ themselves destroyed or the client disconnects.
+ </description>
+ </request>
+
+ <request name="bind">
+ <description summary="request a global hotkey">
+ Request that the given key combination be bound as a global hotkey.
+
+ The keysym identifies the trigger key using the keysym numbering shared
+ with the keymap delivered over wl_keyboard (for the xkb_v1 keymap format,
+ an XKB_KEY_* value), taken in its unshifted form (XKB_KEY_b not XKB_KEY_B). A compositor SHOULD match
+ it against any of the user's layout groups, so a binding keeps firing after
+ the user switches layout group. This is one deliberately specified rule,
+ not a universal one: other platforms differ (macOS matches a physical
+ keycode, X11 re-grabs per layout, Windows tracks the active layout's key),
+ and no rule serves a layout that never produces the keysym; the single
+ documented rule is chosen for predictability across compositors.
+
+ The request creates an vicinae_hotkey_v1 object immediately, but the binding
+ is not active yet. The compositor replies asynchronously with either the
+ "bound" event (the hotkey is now active) or the "denied" event (the
+ request was rejected, with a reason).
+
+ seat is the wl_seat whose keyboard should trigger the hotkey, or null to
+ request it on all of the client's seats. Most clients pass null; seat is
+ provided for completeness on multi-seat systems.
+
+ app_id identifies the requesting application, for use in the compositor's
+ policy and any user-facing audit UI. Where the application has a desktop
+ entry, app_id SHOULD be its desktop file ID as defined by the
+ freedesktop.org Desktop Entry specification, that is the .desktop file
+ name with the .desktop suffix removed (for example "org.example.Launcher"),
+ matching the convention used by xdg_toplevel.set_app_id so a compositor
+ can correlate the two. It is advisory and may be spoofed; a compositor
+ that needs a trustworthy identity SHOULD obtain it through the
+ security-context mechanism rather than relying on this string.
+
+ description is a human-readable, localized description of what the hotkey
+ does (e.g. "Toggle the launcher"), for display in compositor UI or even in this protocol error messages.
+ </description>
+ <arg name="id" type="new_id" interface="vicinae_hotkey_v1"
+ summary="the new hotkey object"/>
+ <arg name="keysym" type="uint" summary="keysym of the trigger key (see description)"/>
+ <arg name="modifiers" type="uint" enum="modifiers"
+ summary="bitmask of required modifiers"/>
+ <arg name="seat" type="object" interface="wl_seat" allow-null="true"
+ summary="target seat, or null for all of the client's seats"/>
+ <arg name="app_id" type="string"
+ summary="advisory application identifier (SHOULD be the desktop file ID)"/>
+ <arg name="description" type="string"
+ summary="human-readable action description"/>
+ </request>
+ </interface>
+
+ <interface name="vicinae_hotkey_v1" version="1">
+ <description summary="a single global hotkey">
+ Represents one requested global hotkey. Its binding is ephemeral: it is
+ released when this object is destroyed or when the client disconnects, and
+ it is never written to any persistent store.
+
+ After bind, exactly one of "bound" or "denied" is sent. While the hotkey is
+ bound, "pressed" and "released" are sent as the combination is activated.
+ The compositor may send "revoked" at any time after "bound" to indicate the
+ binding is no longer active (for example because the user removed it, or a
+ higher-priority binding took the combination).
+ </description>
+
+ <enum name="deny_reason">
+ <description summary="why a bind request was denied">
+ Carried by the "denied" event. This is a reason code, not a fatal
+ protocol error: the object remains valid and the client should destroy
+ it (or try a different combination).
+ </description>
+ <entry name="already_bound" value="0"
+ summary="already held by another hotkey the compositor treats as exclusive. The compositor is not obligated to give this level of detail and can just give not_permitted as the general 'deny' reason."/>
+ <entry name="not_permitted" value="1"
+ summary="the combination is disallowed by compositor policy"/>
+ <entry name="invalid" value="2"
+ summary="the keysym or combination is not a valid trigger"/>
+ </enum>
+
+ <enum name="revoke_reason">
+ <description summary="why a previously bound hotkey was withdrawn">
+ Carried by the "revoked" event. This is a reason code, not a fatal
+ protocol error: the object remains valid and the client should destroy
+ it.
+
+ The distinction is actionable: on "superseded" the combination may
+ become available again, so a client may reasonably re-request it later;
+ on "removed" the withdrawal is a deliberate user or compositor decision,
+ so a client MUST NOT silently re-request the same combination. A client
+ that receives a value it does not recognise (a future addition) MUST
+ treat it conservatively as "removed" and not auto-rebind.
+ </description>
+ <entry name="removed" value="0"
+ summary="withdrawn by the user or compositor; do not auto-rebind"/>
+ <entry name="superseded" value="1"
+ summary="a higher-priority binding took the combination; it may become available again"/>
+ <entry name="not_permitted" value="2"
+ summary="the combination is no longer allowed by compositor policy"/>
+ </enum>
+
+ <request name="destroy" type="destructor">
+ <description summary="release the hotkey">
+ Release the binding and destroy the object. The combination becomes
+ available again immediately. It is valid to destroy the object before
+ receiving "bound" or "denied", which cancels the pending request.
+ </description>
+ </request>
+
+ <event name="bound">
+ <description summary="the hotkey is now active">
+ The requested combination was accepted and is now active. "pressed" and
+ "released" events may follow.
+ </description>
+ </event>
+
+ <event name="denied">
+ <description summary="the request was rejected">
+ The requested combination was not bound. No input events will be sent.
+ The client should destroy this object; it may then create a new request
+ with a different combination.
+
+ message is an optional, advisory, human-readable explanation in the
+ compositor's locale, intended for verbatim display in client UI (for
+ example a "change shortcut" settings screen). It may be empty, and a
+ compositor is never required to provide it. Clients MUST NOT parse it or
+ rely on its contents in any way; all programmatic behavior keys off
+ reason. A client with nowhere to show it simply ignores it.
+ </description>
+ <arg name="reason" type="uint" enum="deny_reason" summary="why it was rejected"/>
+ <arg name="message" type="string"
+ summary="optional human-readable detail for display (may be empty)"/>
+ </event>
+
+ <event name="revoked">
+ <description summary="a previously active hotkey was withdrawn">
+ A binding that was previously "bound" is no longer active. No further
+ input events will be sent for it. The client should destroy this object;
+ it may request the combination again later.
+
+ message is an optional, advisory, human-readable explanation in the
+ compositor's locale, intended for verbatim display in client UI. It may
+ be empty, and a compositor is never required to provide it. Clients MUST
+ NOT parse it or rely on its contents in any way; all programmatic
+ behavior keys off reason. A client with nowhere to show it simply
+ ignores it.
+ </description>
+ <arg name="reason" type="uint" enum="revoke_reason" summary="why it was withdrawn"/>
+ <arg name="message" type="string"
+ summary="optional human-readable detail for display (may be empty)"/>
+ </event>
+
+ <event name="pressed">
+ <description summary="the hotkey combination was pressed">
+ The bound combination was activated.
+
+ This event is sent once per activation. While the combination is held
+ down no further "pressed" events are sent, and a single "released"
+ follows when the trigger key is released: the compositor does not
+ auto-repeat the hotkey.
+
+ serial is a compositor-issued input serial that counts as a recent user
+ interaction, so the client can act on the hotkey even though the
+ triggering key press is not otherwise delivered to it (the compositor
+ consumes it). In particular, a client may pass this serial to
+ xdg_activation_token_v1.set_serial in order to raise or focus a surface in
+ response to the hotkey; a compositor SHOULD honor activation carried by
+ this serial, since the hotkey is itself a deliberate user action. The
+ serial is drawn from the same space as other input event serials.
+
+ time is the event timestamp, with the same millisecond clock as wl_pointer
+ and wl_keyboard input events; it is useful for ordering and for measuring
+ press-to-release duration.
+ </description>
+ <arg name="serial" type="uint" summary="input serial for the activation (e.g. xdg-activation)"/>
+ <arg name="time" type="uint" summary="timestamp in milliseconds"/>
+ </event>
+
+ <event name="released">
+ <description summary="the hotkey combination was released">
+ The trigger key of a previously pressed combination was released. This
+ enables press-and-hold uses (e.g. push-to-talk); clients that only act
+ on activation may ignore it.
+
+ serial and time have the same meaning as in the "pressed" event.
+ </description>
+ <arg name="serial" type="uint" summary="input serial for the activation (e.g. xdg-activation)"/>
+ <arg name="time" type="uint" summary="timestamp in milliseconds"/>
+ </event>
+ </interface>
+</protocol>
diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig
index 8109959d..fe0e6f61 100644
--- a/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig
+++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig
@@ -19,6 +19,7 @@ pub const c = @cImport({
const input = @import("../../../input.zig");
const Config = @import("../../../config.zig").Config;
const ApprtWindow = @import("../class/window.zig").Window;
+const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts;
const BlurRegion = @import("BlurRegion.zig");
const log = std.log.scoped(.gtk_x11);
@@ -158,6 +159,16 @@ pub const App = struct {
return mods;
}
+ pub fn bindGlobalShortcuts(
+ _: *App,
+ _: *GlobalShortcuts,
+ _: *const Config,
+ ) bool {
+ return false;
+ }
+
+ pub fn clearGlobalShortcuts(_: *App) void {}
+
pub fn supportsQuickTerminal(_: App) bool {
log.warn("quick terminal is not yet supported on X11", .{});
return false;
@@ -317,7 +328,7 @@ pub const Window = struct {
self.last_applied_decoration_hints = hints;
}
- pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
+ pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void {
var buf: [64]u8 = undefined;
const window_id = try std.fmt.bufPrint(
&buf,
diff --git a/vendor/libghostty-vt/src/apprt/ipc.zig b/vendor/libghostty-vt/src/apprt/ipc.zig
index dda794ae..d5d860a7 100644
--- a/vendor/libghostty-vt/src/apprt/ipc.zig
+++ b/vendor/libghostty-vt/src/apprt/ipc.zig
@@ -70,23 +70,82 @@ pub const Action = union(enum) {
// entry. If the value type is void then only the key needs to be
// added. Ensure the order matches exactly with the Zig code.
- /// The arguments to pass to Ghostty as the command.
+ /// Create a new window.
new_window: NewWindow,
+ /// Create a new tab.
+ new_tab: NewTab,
+
/// Toggle the quick terminal.
toggle_quick_terminal: void,
pub const NewWindow = struct {
- /// A list of command arguments to launch in the new window. If this is
- /// `null` the command configured in the config or the user's default
- /// shell should be launched.
+ /// A list of configuration entries that will override the configuration
+ /// of the first surface of the new window. If this is `null` there
+ /// are no overrides. Note that not all configuration entries may be
+ /// overridden.
///
/// It is an error for this to be non-`null`, but zero length.
arguments: ?[][:0]const u8,
pub const C = extern struct {
- /// null terminated list of arguments
- /// it will be null itself if there are no arguments
+ /// A list of configuration entries that will override the
+ /// configuration of the first surface of the new tab. If this is
+ /// `null` there are no overrides. Note that not all configuration
+ /// entries may be overridden.
+ ///
+ /// It is an error for this to be non-`null`, but zero length.
+ arguments: ?[*]?[*:0]const u8,
+
+ pub fn deinit(self: *NewWindow.C, alloc: Allocator) void {
+ if (self.arguments) |arguments| alloc.free(arguments);
+ }
+ };
+
+ pub fn cval(self: *NewWindow, alloc: Allocator) Allocator.Error!NewWindow.C {
+ var result: NewWindow.C = undefined;
+
+ if (self.arguments) |arguments| {
+ result.arguments = try alloc.alloc([*:0]const u8, arguments.len + 1);
+
+ for (arguments, 0..) |argument, i|
+ result.arguments[i] = argument.ptr;
+
+ // add null terminator
+ result.arguments[arguments.len] = null;
+ } else {
+ result.arguments = null;
+ }
+
+ return result;
+ }
+ };
+
+ pub const NewTab = struct {
+ /// The unique ID of a surface. If the ID is zero the currently focused
+ /// surface is assumed. This will be used to identify the window to add
+ /// the tab to.
+ surface_id: u64,
+
+ /// A list of configuration entries that will override the configuration
+ /// of the first surface of the new tab. If this is `null` there
+ /// are no overrides. Note that not all configuration entries may be
+ /// overridden.
+ ///
+ /// It is an error for this to be non-`null`, but zero length.
+ arguments: ?[][:0]const u8,
+
+ pub const C = extern struct {
+ /// The unique ID of a surface. If the ID is zero the currently
+ /// focused surface is assumed. This will be used to identify the
+ /// window to add the tab to.
+ surface_id: u64,
+ /// A list of configuration entries that will override the
+ /// configuration of the first surface of the new tab. If this is
+ /// `null` there are no overrides. Note that not all configuration
+ /// entries may be overridden.
+ ///
+ /// It is an error for this to be non-`null`, but zero length.
arguments: ?[*]?[*:0]const u8,
pub fn deinit(self: *NewWindow.C, alloc: Allocator) void {
@@ -116,6 +175,7 @@ pub const Action = union(enum) {
/// Sync with: ghostty_ipc_action_tag_e
pub const Key = enum(c_int) {
new_window,
+ new_tab,
toggle_quick_terminal,
test "ghostty.h Action.Key" {
@@ -126,8 +186,12 @@ pub const Action = union(enum) {
/// Sync with: ghostty_ipc_action_u
pub const CValue = cvalue: {
const key_fields = @typeInfo(Key).@"enum".fields;
- var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined;
- for (key_fields, 0..) |field, i| {
+
+ var names: [key_fields.len][]const u8 = undefined;
+ var types: [key_fields.len]type = undefined;
+ var attrs: [key_fields.len]std.builtin.Type.UnionField.Attributes = undefined;
+
+ for (key_fields, &names, &types, &attrs) |field, *name, *ty, *attr| {
const action = @unionInit(Action, field.name, undefined);
const Type = t: {
const Type = @TypeOf(@field(action, field.name));
@@ -135,20 +199,12 @@ pub const Action = union(enum) {
if (Type != void and @hasDecl(Type, "C")) break :t Type.C;
break :t Type;
};
-
- union_fields[i] = .{
- .name = field.name,
- .type = Type,
- .alignment = @alignOf(Type),
- };
+ name.* = field.name;
+ ty.* = Type;
+ attr.* = .{ .@"align" = @alignOf(Type) };
}
- break :cvalue @Type(.{ .@"union" = .{
- .layout = .@"extern",
- .tag_type = null,
- .fields = &union_fields,
- .decls = &.{},
- } });
+ break :cvalue @Union(.@"extern", null, &names, &types, &attrs);
};
/// Sync with: ghostty_ipc_action_s
@@ -162,8 +218,8 @@ pub const Action = union(enum) {
// At the time of writing, we don't promise ABI compatibility
// so we can change this but I want to be aware of it.
assert(@sizeOf(CValue) == switch (@sizeOf(usize)) {
- 4 => 4,
- 8 => 8,
+ 4 => 12,
+ 8 => 16,
else => unreachable,
});
}
diff --git a/vendor/libghostty-vt/src/apprt/structs.zig b/vendor/libghostty-vt/src/apprt/structs.zig
index 2c37dbd5..d8510fb8 100644
--- a/vendor/libghostty-vt/src/apprt/structs.zig
+++ b/vendor/libghostty-vt/src/apprt/structs.zig
@@ -1,4 +1,6 @@
+const std = @import("std");
const build_config = @import("../build_config.zig");
+const terminal = @import("../terminal/main.zig");
/// ContentScale is the ratio between the current DPI and the platform's
/// default DPI. This is used to determine how much certain rendered elements
@@ -67,13 +69,35 @@ pub const ClipboardRequestType = enum(u8) {
paste,
osc_52_read,
osc_52_write,
+ kitty_read,
+ kitty_write,
+ list,
+};
+
+/// The result of starting a clipboard read request. This only reports
+/// facts about the clipboard; how each state is answered on the wire
+/// (if at all) is up to the protocol handling of the requester.
+///
+/// If this is changed, you must also update ghostty.h
+pub const ClipboardReadResult = enum(c_int) {
+ /// The request was started and will be completed asynchronously
+ /// via the core surface completeClipboardRequest API.
+ started = 0,
+
+ /// The clipboard exists but has no contents the apprt can serve
+ /// (e.g. no text-like data). The request was not started.
+ unavailable = 1,
+
+ /// The clipboard itself can't be read (e.g. a primary selection on
+ /// a platform without one). The request was not started.
+ unsupported = 2,
};
/// Clipboard request. This is used to request clipboard contents and must
/// be sent as a response to a ClipboardRequest event.
pub const ClipboardRequest = union(ClipboardRequestType) {
/// A direct paste of clipboard contents.
- paste: void,
+ paste: Clipboard,
/// A request to read clipboard contents via OSC 52.
osc_52_read: Clipboard,
@@ -81,6 +105,113 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
/// A request to write clipboard contents via OSC 52.
osc_52_write: Clipboard,
+ /// A request to read clipboard contents via the Kitty clipboard
+ /// protocol (OSC 5522).
+ kitty_read: *KittyRead,
+
+ /// A request to write clipboard contents via the Kitty clipboard
+ /// protocol (OSC 5522), carrying a fully committed transaction.
+ kitty_write: *KittyWrite,
+
+ /// A request to list the available clipboard MIME types without
+ /// reading any of their data.
+ list: Clipboard,
+
+ /// State for one in-flight Kitty clipboard protocol read. This is
+ /// created on the IO thread and completed on the app thread, so it
+ /// owns all of its memory: everything, including the struct itself,
+ /// is allocated from the arena.
+ pub const KittyRead = struct {
+ arena: std.heap.ArenaAllocator,
+
+ /// The clipboard being read. The protocol can only name the
+ /// standard clipboard or the primary selection.
+ location: Clipboard,
+
+ /// The requested MIME types in request order, already capped at
+ /// terminal.kitty.clipboard.max_read_mimes by the sender. Only
+ /// these representations may be served in the response. The
+ /// values are sentinel-terminated so they can cross a C apprt
+ /// boundary without copies.
+ mimes: []const [:0]const u8,
+
+ /// True when the targets ('.') listing was requested.
+ list: bool,
+
+ /// The sanitized request id, echoed in every response packet.
+ id: []const u8,
+
+ /// The effective session password, empty when the request had
+ /// none. A non-empty password means the user's decision may be
+ /// remembered as a session grant.
+ pw: []const u8,
+
+ /// The human friendly name of the requesting program, shown in
+ /// permission prompts. Empty when absent. Sentinel-terminated
+ /// so it can cross a C apprt boundary without copies.
+ name: [:0]const u8,
+
+ /// True when a stored session grant already covers this
+ /// request, so any permission prompt is skipped.
+ granted: bool,
+
+ /// The response terminator, matching the request's.
+ terminator: terminal.osc.Terminator,
+
+ pub fn destroy(self: *KittyRead) void {
+ // The struct itself lives in the arena, so move the arena
+ // out before tearing it down.
+ var arena = self.arena;
+ arena.deinit();
+ }
+ };
+
+ /// State for one committed Kitty clipboard protocol write
+ /// transaction. Like KittyRead, this is created on the IO thread
+ /// and completed on the app thread, so everything, including the
+ /// struct itself, is allocated from the arena.
+ pub const KittyWrite = struct {
+ arena: std.heap.ArenaAllocator,
+
+ /// The clipboard being written. The protocol can only name the
+ /// standard clipboard or the primary selection.
+ location: Clipboard,
+
+ /// The committed representations. These are the authoritative
+ /// contents of the write: completions apply these rather than
+ /// any contents echoed back by the apprt. The values are
+ /// sentinel-terminated so they can cross a C apprt boundary
+ /// without copies; data is binary-safe via its length.
+ contents: []const ClipboardContent,
+
+ /// The sanitized request id, echoed in every response packet.
+ id: []const u8,
+
+ /// The effective session password, empty when the request had
+ /// none. A non-empty password means the user's decision may be
+ /// remembered as a session grant.
+ pw: []const u8,
+
+ /// The human friendly name of the requesting program, shown in
+ /// permission prompts. Empty when absent. Sentinel-terminated
+ /// so it can cross a C apprt boundary without copies.
+ name: [:0]const u8,
+
+ /// True when a stored session grant already covers this
+ /// request, so any permission prompt is skipped.
+ granted: bool,
+
+ /// The response terminator, matching the request's.
+ terminator: terminal.osc.Terminator,
+
+ pub fn destroy(self: *KittyWrite) void {
+ // The struct itself lives in the arena, so move the arena
+ // out before tearing it down.
+ var arena = self.arena;
+ arena.deinit();
+ }
+ };
+
/// Make this a valid gobject if we're in a GTK environment.
pub const getGObjectType = switch (build_config.app_runtime) {
.gtk => @import("gobject").ext.defineBoxed(
diff --git a/vendor/libghostty-vt/src/apprt/surface.zig b/vendor/libghostty-vt/src/apprt/surface.zig
index 3cb0016f..51da3374 100644
--- a/vendor/libghostty-vt/src/apprt/surface.zig
+++ b/vendor/libghostty-vt/src/apprt/surface.zig
@@ -16,6 +16,47 @@ pub const Message = union(enum) {
/// we want this union to be.
pub const WriteReq = MessageData(u8, 255);
+ /// A fixed-size desktop notification payload sent to the app thread.
+ pub const DesktopNotification = struct {
+ /// Desktop notification title.
+ title: [63:0]u8,
+
+ /// Desktop notification body.
+ body: [255:0]u8,
+
+ pub fn init(title: []const u8, body: []const u8) DesktopNotification {
+ var result: DesktopNotification = undefined;
+ copyUtf8Z(result.title.len, &result.title, title);
+ copyUtf8Z(result.body.len, &result.body, body);
+ return result;
+ }
+
+ /// UTF-8 continuation bytes occupy the range 0x80 through 0xBF.
+ fn isUtf8ContinuationByte(byte: u8) bool {
+ return switch (byte) {
+ 0x80...0xBF => true,
+ else => false,
+ };
+ }
+
+ /// Copy as much of `src` as fits, backing up from a UTF-8 continuation
+ /// byte so valid input is never truncated in the middle of a codepoint.
+ fn copyUtf8Z(
+ comptime capacity: usize,
+ dst: *[capacity:0]u8,
+ src: []const u8,
+ ) void {
+ var len = @min(src.len, capacity);
+ while (len > 0 and
+ len < src.len and
+ isUtf8ContinuationByte(src[len])) : (len -= 1)
+ {}
+
+ @memcpy(dst[0..len], src[0..len]);
+ dst[len] = 0;
+ }
+ };
+
/// Set the title of the surface.
/// TODO: we should change this to a "WriteReq" style structure in
/// the termio message so that we can more efficiently send strings
@@ -31,6 +72,16 @@ pub const Message = union(enum) {
/// Read the clipboard and write to the pty.
clipboard_read: apprt.Clipboard,
+ /// A Kitty clipboard protocol (OSC 5522) read request. The receiver
+ /// takes ownership of the request state and must eventually destroy
+ /// it.
+ kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead,
+
+ /// A committed Kitty clipboard protocol (OSC 5522) write
+ /// transaction. The receiver takes ownership of the request state
+ /// and must eventually destroy it.
+ kitty_clipboard_write: *apprt.ClipboardRequest.KittyWrite,
+
/// Write the clipboard contents.
clipboard_write: struct {
clipboard_type: apprt.Clipboard,
@@ -52,13 +103,7 @@ pub const Message = union(enum) {
child_exited: ChildExited,
/// Show a desktop notification.
- desktop_notification: struct {
- /// Desktop notification title.
- title: [63:0]u8,
-
- /// Desktop notification body.
- body: [255:0]u8,
- },
+ desktop_notification: DesktopNotification,
/// Health status change for the renderer.
renderer_health: renderer.Health,
@@ -195,3 +240,78 @@ pub fn newConfig(
return copy;
}
+
+test "DesktopNotification init" {
+ const notification = Message.DesktopNotification.init("Title", "Body");
+
+ try std.testing.expectEqualStrings("Title", std.mem.sliceTo(&notification.title, 0));
+ try std.testing.expectEqualStrings("Body", std.mem.sliceTo(&notification.body, 0));
+}
+
+test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" {
+ const DesktopNotification = Message.DesktopNotification;
+
+ var dst_1_byte: [1:0]u8 = undefined;
+ const src_ending_in_1_byte_codepoint = "ab";
+ try std.testing.expect(!DesktopNotification.isUtf8ContinuationByte(
+ src_ending_in_1_byte_codepoint[dst_1_byte.len],
+ ));
+ DesktopNotification.copyUtf8Z(
+ dst_1_byte.len,
+ &dst_1_byte,
+ src_ending_in_1_byte_codepoint,
+ );
+ try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0));
+
+ var dst_2_bytes: [2:0]u8 = undefined;
+ const src_ending_in_2_byte_codepoint = "aЯ";
+ try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
+ src_ending_in_2_byte_codepoint[dst_2_bytes.len],
+ ));
+ DesktopNotification.copyUtf8Z(
+ dst_2_bytes.len,
+ &dst_2_bytes,
+ src_ending_in_2_byte_codepoint,
+ );
+ try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0));
+
+ var dst_3_bytes: [3:0]u8 = undefined;
+ const src_ending_in_3_byte_codepoint = "a€";
+ try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
+ src_ending_in_3_byte_codepoint[dst_3_bytes.len],
+ ));
+ DesktopNotification.copyUtf8Z(
+ dst_3_bytes.len,
+ &dst_3_bytes,
+ src_ending_in_3_byte_codepoint,
+ );
+ try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0));
+
+ var dst_4_bytes: [4:0]u8 = undefined;
+ const src_ending_in_4_byte_codepoint = "a😀";
+ try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
+ src_ending_in_4_byte_codepoint[dst_4_bytes.len],
+ ));
+ DesktopNotification.copyUtf8Z(
+ dst_4_bytes.len,
+ &dst_4_bytes,
+ src_ending_in_4_byte_codepoint,
+ );
+ try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0));
+}
+
+test "copyUtf8Z keeps a complete codepoint at the truncation boundary" {
+ var dst: [5:0]u8 = undefined;
+
+ Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯz");
+
+ try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0));
+}
+
+test "copyUtf8Z preserves UTF-8 that fits" {
+ var dst: [5:0]u8 = undefined;
+
+ Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯ");
+
+ try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0));
+}