mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-25 13:16:11 +03:00
apprt/gtk-ng: clipboard support (#8030)
This ports over read/write clipboard to gtk-ng. This was a surprisingly massive amount of work! The clipboard confirmation dialog is non-trivial: it supports multiple read/write types, blurring, remember choice, and spans multiple Adw versions. I was able to port all of the functionality into a single `CloseConfirmationDialog` class and make use of a good amount of Blueprint binds to simplify some stuff.
This commit is contained in:
@ -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 {
|
||||
|
@ -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" },
|
||||
|
398
src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig
Normal file
398
src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig
Normal file
@ -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;
|
||||
};
|
||||
};
|
@ -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,31 +1292,42 @@ 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| {
|
||||
_ = 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(
|
||||
gesture: *gtk.GestureClick,
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
82
src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp
Normal file
82
src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp
Normal file
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
92
src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp
Normal file
92
src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp
Normal file
@ -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");
|
||||
}
|
||||
};
|
||||
}
|
@ -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).
|
||||
|
Reference in New Issue
Block a user