diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index e25ec48ee..7613abd2d 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -73,9 +73,10 @@ pub fn clipboardRequest( clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, ) !void { - _ = self; - _ = clipboard_type; - _ = state; + try self.surface.clipboardRequest( + clipboard_type, + state, + ); } pub fn setClipboardString( @@ -84,10 +85,11 @@ pub fn setClipboardString( clipboard_type: apprt.Clipboard, confirm: bool, ) !void { - _ = self; - _ = val; - _ = clipboard_type; - _ = confirm; + self.surface.setClipboardString( + val, + clipboard_type, + confirm, + ); } pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f953faaf6..3e192ebbd 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -33,6 +33,8 @@ 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 = 0, .name = "clipboard-confirmation-dialog" }, + .{ .major = 1, .minor = 4, .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..4814909b3 --- /dev/null +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -0,0 +1,398 @@ +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 i18n = @import("../../../os/main.zig").i18n; +const adw_version = @import("../adw_version.zig"); +const Common = @import("../class.zig").Common; +const Dialog = @import("dialog.zig").Dialog; + +const log = std.log.scoped(.gtk_ghostty_clipboard_confirmation); + +/// Whether we're able to have the remember switch +const can_remember = adw_version.supportsSwitchRow(); + +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 @"can-remember" = struct { + pub const name = "can-remember"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Can Remember", + .blurb = "Allow remembering the choice.", + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "can_remember", + ), + }, + ); + }; + + 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", + ), + }, + ); + }; + + pub const @"clipboard-contents" = struct { + pub const name = "clipboard-contents"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.TextBuffer, + .{ + .nick = "Clipboard Contents", + .blurb = "The clipboard contents being read/written.", + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "clipboard_contents", + ), + }, + ); + }; + + pub const blur = struct { + pub const name = "blur"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Blur", + .blurb = "Blur the contents, allowing the user to reveal.", + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "blur", + ), + }, + ); + }; + }; + + pub const signals = struct { + pub const deny = struct { + pub const name = "deny"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{bool}, + void, + ); + }; + + pub const confirm = struct { + pub const name = "confirm"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{bool}, + void, + ); + }; + }; + + const Private = struct { + /// The request that this dialog is for. + request: ?*apprt.ClipboardRequest = null, + + /// The clipboard contents being read/written. + clipboard_contents: ?*gtk.TextBuffer = null, + + /// Whether the contents should be blurred. + blur: bool = false, + + /// Whether the user can remember the choice. + can_remember: bool = false, + + // Template bindings + text_view_scroll: *gtk.ScrolledWindow, + text_view: *gtk.TextView, + reveal_button: *gtk.Button, + hide_button: *gtk.Button, + remember_choice: if (can_remember) *adw.SwitchRow else void, + + 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)); + + const priv = self.private(); + + // Signals + _ = gtk.Button.signals.clicked.connect( + priv.reveal_button, + *Self, + revealButtonClicked, + self, + .{}, + ); + _ = gtk.Button.signals.clicked.connect( + priv.hide_button, + *Self, + hideButtonClicked, + self, + .{}, + ); + + // Some property signals + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + &propBlur, + null, + .{ .detail = "blur" }, + ); + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + &propRequest, + null, + .{ .detail = "request" }, + ); + + // Trigger initial values + self.propBlur(undefined, null); + self.propRequest(undefined, null); + } + + pub fn present(self: *Self, parent: ?*gtk.Widget) void { + self.as(Dialog).present(parent); + } + + /// Get the clipboard request without copying. + pub fn getRequest(self: *Self) ?*apprt.ClipboardRequest { + return self.private().request; + } + + /// Get the clipboard contents without copying. + pub fn getClipboardContents(self: *Self) ?*gtk.TextBuffer { + return self.private().clipboard_contents; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propBlur( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (priv.blur) { + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); + priv.text_view.as(gtk.Widget).addCssClass("blurred"); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } else { + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); + priv.text_view.as(gtk.Widget).removeCssClass("blurred"); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } + } + + fn propRequest( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const req = priv.request orelse return; + switch (req.*) { + .osc_52_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 => { + 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.")); + }, + .paste => { + 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.")); + }, + } + } + + fn revealButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const priv = self.private(); + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); + priv.text_view.as(gtk.Widget).removeCssClass("blurred"); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(true)); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } + + fn hideButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const priv = self.private(); + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); + priv.text_view.as(gtk.Widget).addCssClass("blurred"); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn response( + self: *Self, + response_id: [*:0]const u8, + ) callconv(.C) void { + const remember: bool = if (comptime can_remember) remember: { + const priv = self.private(); + break :remember priv.remember_choice.getActive() != 0; + } else false; + + if (std.mem.orderZ(u8, response_id, "cancel") == .eq) { + signals.deny.impl.emit( + self, + null, + .{remember}, + null, + ); + } else if (std.mem.orderZ(u8, response_id, "ok") == .eq) { + signals.confirm.impl.emit( + self, + null, + .{remember}, + null, + ); + } + } + + fn dispose(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.clipboard_contents) |v| { + v.unref(); + priv.clipboard_contents = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.request) |v| { + glib.ext.destroy(v); + priv.request = null; + } + + gobject.Object.virtual_methods.finalize.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), + if (comptime adw_version.atLeast(1, 4, 0)) + comptime gresource.blueprint(.{ + .major = 1, + .minor = 4, + .name = "clipboard-confirmation-dialog", + }) + else + comptime gresource.blueprint(.{ + .major = 1, + .minor = 0, + .name = "clipboard-confirmation-dialog", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("text_view_scroll", .{}); + class.bindTemplateChildPrivate("text_view", .{}); + class.bindTemplateChildPrivate("hide_button", .{}); + class.bindTemplateChildPrivate("reveal_button", .{}); + if (comptime can_remember) { + class.bindTemplateChildPrivate("remember_choice", .{}); + } + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.blur.impl, + properties.@"can-remember".impl, + properties.@"clipboard-contents".impl, + properties.request.impl, + }); + + // Signals + signals.confirm.impl.register(.{}); + signals.deny.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + Dialog.virtual_methods.response.implement(class, &response); + } + + 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 fd2e6c54b..12814433a 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const adw = @import("adw"); const gdk = @import("gdk"); +const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); @@ -20,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); @@ -55,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( @@ -168,6 +190,30 @@ pub const Surface = extern struct { void, ); }; + + /// Emitted whenever the clipboard has been written. + pub const @"clipboard-write" = struct { + pub const name = "clipboard-write"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted whenever the surface reads the clipboard. + pub const @"clipboard-read" = struct { + pub const name = "clipboard-read"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; }; const Private = struct { @@ -195,6 +241,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, @@ -690,6 +740,32 @@ pub const Surface = extern struct { return env; } + pub fn clipboardRequest( + self: *Self, + clipboard_type: apprt.Clipboard, + state: apprt.ClipboardRequest, + ) !void { + try Clipboard.request( + self, + clipboard_type, + state, + ); + } + + pub fn setClipboardString( + self: *Self, + val: [:0]const u8, + clipboard_type: apprt.Clipboard, + confirm: bool, + ) void { + Clipboard.set( + self, + val, + clipboard_type, + confirm, + ); + } + //--------------------------------------------------------------- // Virtual Methods @@ -704,6 +780,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. @@ -1215,30 +1292,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 triggered 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( @@ -1853,6 +1941,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, @@ -1862,6 +1951,8 @@ pub const Surface = extern struct { // Signals signals.@"close-request".impl.register(.{}); + signals.@"clipboard-read".impl.register(.{}); + signals.@"clipboard-write".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); @@ -1903,3 +1994,255 @@ fn translateMouseButton(button: c_uint) input.MouseButton { else => .unknown, }; } + +/// A namespace for our clipboard-related functions so Surface isn't SO large. +const Clipboard = struct { + /// Get the specific type of clipboard for a widget. + pub fn get( + widget: *gtk.Widget, + clipboard: apprt.Clipboard, + ) ?*gdk.Clipboard { + return switch (clipboard) { + .standard => widget.getClipboard(), + .selection, .primary => widget.getPrimaryClipboard(), + }; + } + + /// Set the clipboard contents. + pub fn set( + self: *Surface, + val: [:0]const u8, + clipboard_type: apprt.Clipboard, + confirm: bool, + ) void { + const priv = self.private(); + + // If no confirmation is necessary, set the clipboard. + if (!confirm) { + const clipboard = get( + priv.gl_area.as(gtk.Widget), + clipboard_type, + ) orelse return; + clipboard.setText(val); + + Surface.signals.@"clipboard-write".impl.emit( + self, + null, + .{}, + null, + ); + + return; + } + + showClipboardConfirmation( + self, + .{ .osc_52_write = clipboard_type }, + val, + ); + } + + /// Request data from the clipboard (read the clipboard). This + /// 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!void { + // Get our requested clipboard + const clipboard = get( + self.private().gl_area.as(gtk.Widget), + clipboard_type, + ) orelse return; + + // Allocate our userdata + const alloc = Application.default().allocator(); + const ud = try alloc.create(Request); + errdefer alloc.destroy(ud); + ud.* = .{ + // Important: we ref self here so that we can't free memory + // while we have an outstanding clipboard read. + .self = self.ref(), + .state = state, + }; + errdefer self.unref(); + + // Read + clipboard.readTextAsync( + null, + clipboardReadText, + ud, + ); + } + + fn showClipboardConfirmation( + self: *Surface, + req: apprt.ClipboardRequest, + str: [:0]const u8, + ) void { + // Build a text buffer for our contents + const contents_buf: *gtk.TextBuffer = .new(null); + defer contents_buf.unref(); + contents_buf.insertAtCursor(str, @intCast(str.len)); + + // Confirm + const dialog = gobject.ext.newInstance( + ClipboardConfirmationDialog, + .{ + .request = &req, + .@"can-remember" = switch (req) { + .osc_52_read, .osc_52_write => true, + .paste => false, + }, + .@"clipboard-contents" = contents_buf, + }, + ); + + _ = ClipboardConfirmationDialog.signals.confirm.connect( + dialog, + *Surface, + clipboardConfirmationConfirm, + self, + .{}, + ); + _ = ClipboardConfirmationDialog.signals.deny.connect( + dialog, + *Surface, + clipboardConfirmationDeny, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + fn clipboardConfirmationConfirm( + dialog: *ClipboardConfirmationDialog, + remember: bool, + self: *Surface, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.core_surface orelse return; + const req = dialog.getRequest() orelse return; + + // Handle remember + if (remember) switch (req.*) { + .osc_52_read => surface.config.clipboard_read = .allow, + .osc_52_write => surface.config.clipboard_write = .allow, + .paste => {}, + }; + + // Get our text + const text_buf = dialog.getClipboardContents() orelse return; + var text_val = gobject.ext.Value.new(?[:0]const u8); + defer text_val.unset(); + gobject.Object.getProperty( + text_buf.as(gobject.Object), + "text", + &text_val, + ); + const text = gobject.ext.Value.get( + &text_val, + ?[:0]const u8, + ) orelse return; + + surface.completeClipboardRequest( + req.*, + text, + true, + ) catch |err| { + log.warn("failed to complete clipboard request: {}", .{err}); + }; + } + + fn clipboardConfirmationDeny( + dialog: *ClipboardConfirmationDialog, + remember: bool, + self: *Surface, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.core_surface orelse return; + const req = dialog.getRequest() orelse return; + + // Handle remember + 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"), + }; + } + + fn clipboardReadText( + source: ?*gobject.Object, + res: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const clipboard = gobject.ext.cast( + gdk.Clipboard, + source orelse return, + ) orelse return; + const req: *Request = @ptrCast(@alignCast(ud orelse return)); + + const alloc = Application.default().allocator(); + defer alloc.destroy(req); + + const self = req.self; + defer self.unref(); + + var gerr: ?*glib.Error = null; + const cstr_ = clipboard.readTextFinish(res, &gerr); + if (gerr) |err| { + defer err.free(); + log.warn( + "failed to read clipboard err={s}", + .{err.f_message orelse "(no message)"}, + ); + return; + } + const cstr = cstr_ orelse return; + defer glib.free(cstr); + const str = std.mem.sliceTo(cstr, 0); + + const surface = self.private().core_surface orelse return; + surface.completeClipboardRequest( + req.state, + str, + false, + ) catch |err| switch (err) { + error.UnsafePaste, + error.UnauthorizedPaste, + => { + showClipboardConfirmation( + self, + req.state, + str, + ); + return; + }, + + else => { + log.warn( + "failed to complete clipboard request err={}", + .{err}, + ); + return; + }, + }; + + Surface.signals.@"clipboard-read".impl.emit( + self, + null, + .{}, + null, + ); + } + + /// The request we send as userdata to the clipboard read. + const Request = struct { + /// "Self" is reffed so we can't dispose it until the clipboard + /// read is complete. Callers must unref when done. + self: *Surface, + state: apprt.ClipboardRequest, + }; +}; diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index 99515ec4a..e69a4e77d 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -4,6 +4,9 @@ * https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.3/styles-and-appearance.html#custom-styles */ +/* + * GhosttySurface URL overlay + */ label.url-overlay { padding: 4px 8px 4px 8px; outline-style: solid; @@ -23,6 +26,9 @@ label.url-overlay.right { border-radius: 6px 0px 0px 0px; } +/* + * GhosttySurface resize overlay + */ .size-overlay label { padding: 4px 8px 4px 8px; border-radius: 6px 6px 6px 6px; @@ -30,3 +36,36 @@ label.url-overlay.right { outline-width: 1px; outline-color: #555555; } + +/* + * GhosttyClipboardConfirmationDialog + * + * Based on boxed-list-separate: + * https://gitlab.gnome.org/GNOME/libadwaita/-/blob/ad446167acf3e6d1ee693f98ca636268be8592a1/src/stylesheet/widgets/_lists.scss#L548 + */ +.clipboard-confirmation-dialog list { + background: none; +} + +.clipboard-confirmation-dialog list > row { + border: none; + margin-bottom: 12px; +} + +.clipboard-confirmation-dialog list > row:last-child { + margin-bottom: 0; +} + +.clipboard-confirmation-dialog .clipboard-overlay { + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents { + filter: blur(0px); + transition: filter 0.3s ease; + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents.blurred { + filter: blur(5px); +} diff --git a/src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp b/src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp new file mode 100644 index 000000000..c3b760373 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp @@ -0,0 +1,82 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [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", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + }; +} diff --git a/src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp b/src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp new file mode 100644 index 000000000..066927ff2 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp @@ -0,0 +1,92 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [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", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + styles [ + "card", + ] + + visible: bind template.can-remember; + 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).