apprt/gtk-ng: fix focus deadlock

This commit is contained in:
Mitchell Hashimoto
2025-07-22 09:45:35 -07:00
parent a4f494e2ae
commit a91ed99054
5 changed files with 285 additions and 14 deletions

View File

@ -33,6 +33,7 @@ pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 };
/// ///
/// These will be asserted to exist at runtime. /// These will be asserted to exist at runtime.
pub const blueprints: []const Blueprint = &.{ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 2, .name = "clipboard-confirmation-dialog" },
.{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" }, .{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" },
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" },

View File

@ -0,0 +1,118 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const apprt = @import("../../../apprt.zig");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Dialog = @import("dialog.zig").Dialog;
const log = std.log.scoped(.gtk_ghostty_clipboard_confirmation);
pub const ClipboardConfirmationDialog = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = Dialog;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyClipboardConfirmationDialog",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const request = struct {
pub const name = "request";
const impl = gobject.ext.defineProperty(
name,
Self,
?*apprt.ClipboardRequest,
.{
.nick = "Request",
.blurb = "The clipboard request.",
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"request",
),
},
);
};
};
const Private = struct {
/// The request that this dialog is for.
request: ?*apprt.ClipboardRequest = null,
pub var offset: c_int = 0;
};
pub fn new() *Self {
return gobject.ext.newInstance(Self, .{});
}
fn init(self: *Self, _: *Class) callconv(.C) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
pub fn present(self: *Self, parent: ?*gtk.Widget) void {
self.as(Dialog).present(parent);
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.C) void {
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "clipboard-confirmation-dialog",
}),
);
// Bindings
//class.bindTemplateChildPrivate("label", .{});
// Properties
gobject.ext.registerProperties(class, &.{
properties.request.impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
};
};

View File

@ -21,6 +21,7 @@ const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application; const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const log = std.log.scoped(.gtk_ghostty_surface); const log = std.log.scoped(.gtk_ghostty_surface);
@ -56,6 +57,26 @@ pub const Surface = extern struct {
); );
}; };
pub const focused = struct {
pub const name = "focused";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.nick = "Focused",
.blurb = "The focused state of the surface.",
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"focused",
),
},
);
};
pub const @"mouse-hidden" = struct { pub const @"mouse-hidden" = struct {
pub const name = "mouse-hidden"; pub const name = "mouse-hidden";
const impl = gobject.ext.defineProperty( const impl = gobject.ext.defineProperty(
@ -196,6 +217,10 @@ pub const Surface = extern struct {
/// The title of this surface, if any has been set. /// The title of this surface, if any has been set.
title: ?[:0]const u8 = null, title: ?[:0]const u8 = null,
/// The current focus state of the terminal based on the
/// focus events.
focused: bool = true,
/// The overlay we use for things such as the URL hover label /// The overlay we use for things such as the URL hover label
/// or resize box. Bound from the template. /// or resize box. Bound from the template.
overlay: *gtk.Overlay = undefined, overlay: *gtk.Overlay = undefined,
@ -731,6 +756,7 @@ pub const Surface = extern struct {
priv.cursor_pos = .{ .x = 0, .y = 0 }; priv.cursor_pos = .{ .x = 0, .y = 0 };
priv.mouse_shape = .text; priv.mouse_shape = .text;
priv.mouse_hidden = false; priv.mouse_hidden = false;
priv.focused = true;
priv.size = .{ priv.size = .{
// Funky numbers on purpose so they stand out if for some reason // Funky numbers on purpose so they stand out if for some reason
// our size doesn't get properly set. // our size doesn't get properly set.
@ -1242,31 +1268,42 @@ pub const Surface = extern struct {
fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
const priv = self.private(); const priv = self.private();
priv.focused = true;
if (priv.im_context) |im_context| { if (priv.im_context) |im_context| {
im_context.as(gtk.IMContext).focusIn(); im_context.as(gtk.IMContext).focusIn();
} }
if (priv.core_surface) |surface| { _ = glib.idleAddOnce(idleFocus, self.ref());
surface.focusCallback(true) catch |err| {
log.warn("error in focus callback err={}", .{err});
};
}
} }
fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
const priv = self.private(); const priv = self.private();
priv.focused = false;
if (priv.im_context) |im_context| { if (priv.im_context) |im_context| {
im_context.as(gtk.IMContext).focusOut(); im_context.as(gtk.IMContext).focusOut();
} }
if (priv.core_surface) |surface| { _ = glib.idleAddOnce(idleFocus, self.ref());
surface.focusCallback(false) catch |err| { }
/// The focus callback must be triggerd on an idle loop source because
/// there are actions within libghostty callbacks (such as showing close
/// confirmation dialogs) that can trigger focus loss and cause a deadlock
/// because the lock may be held during the callback.
///
/// Userdata should be a `*Surface`. This will unref once.
fn idleFocus(ud: ?*anyopaque) callconv(.c) void {
const self: *Self = @ptrCast(@alignCast(ud orelse return));
defer self.unref();
const priv = self.private();
const surface = priv.core_surface orelse return;
surface.focusCallback(priv.focused) catch |err| {
log.warn("error in focus callback err={}", .{err}); log.warn("error in focus callback err={}", .{err});
}; };
} }
}
fn gcMouseDown( fn gcMouseDown(
gesture: *gtk.GestureClick, gesture: *gtk.GestureClick,
@ -1880,6 +1917,7 @@ pub const Surface = extern struct {
// Properties // Properties
gobject.ext.registerProperties(class, &.{ gobject.ext.registerProperties(class, &.{
properties.config.impl, properties.config.impl,
properties.focused.impl,
properties.@"mouse-shape".impl, properties.@"mouse-shape".impl,
properties.@"mouse-hidden".impl, properties.@"mouse-hidden".impl,
properties.@"mouse-hover-url".impl, properties.@"mouse-hover-url".impl,
@ -1951,10 +1989,31 @@ const Clipboard = struct {
clipboard_type: apprt.Clipboard, clipboard_type: apprt.Clipboard,
confirm: bool, confirm: bool,
) void { ) void {
_ = self; const priv = self.private();
_ = val;
_ = clipboard_type; // If no confirmation is necessary, set the clipboard.
_ = confirm; if (!confirm and false) {
const clipboard = get(
priv.gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
clipboard.setText(val);
return;
}
// Confirm
const diag = gobject.ext.newInstance(
ClipboardConfirmationDialog,
.{ .request = &apprt.ClipboardRequest{
.osc_52_write = clipboard_type,
} },
);
// We need to trigger the dialog
diag.present(self.as(gtk.Widget));
log.warn("TODO: confirmation window", .{});
} }
/// Request data from the clipboard (read the clipboard). This /// Request data from the clipboard (read the clipboard). This

View File

@ -0,0 +1,81 @@
using Gtk 4.0;
// This is unused but if we remove it we get a blueprint-compiler error.
using Adw 1;
template $GhosttyClipboardConfirmationDialog: $GhosttyDialog {
heading: _("Authorize Clipboard Access");
body: _("An application is attempting to write to the clipboard. The current clipboard contents are shown below.");
responses [
cancel: _("Deny") suggested,
ok: _("Allow") destructive,
]
default-response: "cancel";
close-response: "cancel";
extra-child: ListBox {
selection-mode: none;
styles [
"boxed-list-separate",
]
Overlay {
styles [
"osd",
"clipboard-overlay",
]
ScrolledWindow text_view_scroll {
width-request: 500;
height-request: 200;
TextView text_view {
cursor-visible: false;
editable: false;
monospace: true;
top-margin: 8;
left-margin: 8;
bottom-margin: 8;
right-margin: 8;
styles [
"clipboard-content-view",
]
}
}
[overlay]
Button reveal_button {
visible: false;
halign: end;
valign: start;
margin-end: 12;
margin-top: 12;
Image {
icon-name: "view-reveal-symbolic";
}
}
[overlay]
Button hide_button {
visible: false;
halign: end;
valign: start;
margin-end: 12;
margin-top: 12;
styles [
"opaque",
]
}
}
Adw.SwitchRow remember_choice {
title: _("Remember choice for this split");
subtitle: _("Reload configuration to show this prompt again");
}
};
}

View File

@ -1,3 +1,5 @@
const build_config = @import("../build_config.zig");
/// ContentScale is the ratio between the current DPI and the platform's /// ContentScale is the ratio between the current DPI and the platform's
/// default DPI. This is used to determine how much certain rendered elements /// default DPI. This is used to determine how much certain rendered elements
/// need to be scaled up or down. /// need to be scaled up or down.
@ -50,6 +52,16 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
/// A request to write clipboard contents via OSC 52. /// A request to write clipboard contents via OSC 52.
osc_52_write: Clipboard, osc_52_write: Clipboard,
/// Make this a valid gobject if we're in a GTK environment.
pub const getGObjectType = switch (build_config.app_runtime) {
.gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed(
ClipboardRequest,
.{ .name = "GhosttyClipboardRequest" },
),
.none => void,
};
}; };
/// The color scheme in use (light vs dark). /// The color scheme in use (light vs dark).