From a91ed99054a90c4b665fe849a91cc8e1d457b809 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 22 Jul 2025 09:45:35 -0700 Subject: [PATCH] apprt/gtk-ng: fix focus deadlock --- src/apprt/gtk-ng/build/gresource.zig | 1 + .../class/clipboard_confirmation_dialog.zig | 118 ++++++++++++++++++ src/apprt/gtk-ng/class/surface.zig | 87 ++++++++++--- .../ui/1.2/clipboard-confirmation-dialog.blp | 81 ++++++++++++ src/apprt/structs.zig | 12 ++ 5 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig create mode 100644 src/apprt/gtk-ng/ui/1.2/clipboard-confirmation-dialog.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f953faaf6..4e608278c 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -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. 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 = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig new file mode 100644 index 000000000..021072197 --- /dev/null +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -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; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index b27fe1da1..6e8e717cb 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -21,6 +21,7 @@ const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; 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 name = "mouse-hidden"; const impl = gobject.ext.defineProperty( @@ -196,6 +217,10 @@ pub const Surface = extern struct { /// The title of this surface, if any has been set. 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 /// or resize box. Bound from the template. overlay: *gtk.Overlay = undefined, @@ -731,6 +756,7 @@ pub const Surface = extern struct { priv.cursor_pos = .{ .x = 0, .y = 0 }; priv.mouse_shape = .text; priv.mouse_hidden = false; + priv.focused = true; priv.size = .{ // Funky numbers on purpose so they stand out if for some reason // our size doesn't get properly set. @@ -1242,30 +1268,41 @@ pub const Surface = extern struct { fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { const priv = self.private(); + priv.focused = true; if (priv.im_context) |im_context| { im_context.as(gtk.IMContext).focusIn(); } - if (priv.core_surface) |surface| { - surface.focusCallback(true) catch |err| { - log.warn("error in focus callback err={}", .{err}); - }; - } + _ = glib.idleAddOnce(idleFocus, self.ref()); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { const priv = self.private(); + priv.focused = false; if (priv.im_context) |im_context| { im_context.as(gtk.IMContext).focusOut(); } - if (priv.core_surface) |surface| { - surface.focusCallback(false) catch |err| { - log.warn("error in focus callback err={}", .{err}); - }; - } + _ = glib.idleAddOnce(idleFocus, self.ref()); + } + + /// 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}); + }; } fn gcMouseDown( @@ -1880,6 +1917,7 @@ pub const Surface = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.config.impl, + properties.focused.impl, properties.@"mouse-shape".impl, properties.@"mouse-hidden".impl, properties.@"mouse-hover-url".impl, @@ -1951,10 +1989,31 @@ const Clipboard = struct { clipboard_type: apprt.Clipboard, confirm: bool, ) void { - _ = self; - _ = val; - _ = clipboard_type; - _ = confirm; + const priv = self.private(); + + // If no confirmation is necessary, set the clipboard. + 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 diff --git a/src/apprt/gtk-ng/ui/1.2/clipboard-confirmation-dialog.blp b/src/apprt/gtk-ng/ui/1.2/clipboard-confirmation-dialog.blp new file mode 100644 index 000000000..efd7c5014 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.2/clipboard-confirmation-dialog.blp @@ -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"); + } + }; +} diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index e2e9b913d..1c3b28723 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -1,3 +1,5 @@ +const build_config = @import("../build_config.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 /// 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. 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).