gtk: implement dropping files and strings (#4211)

This allows dropping files and strings onto Ghostty in the GTK apprt. If
you drop files onto Ghostty it will be pasted as a list of shell-escaped
paths separated by newlines. If you drop a string onto Ghostty it will
paste the string. Normal rules for pasting (bracketed pasts, unsafe
pastes) apply.
This commit is contained in:
Mitchell Hashimoto
2025-01-08 13:12:09 -08:00
committed by GitHub
3 changed files with 201 additions and 0 deletions

View File

@ -497,6 +497,17 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
c.gtk_widget_set_focusable(gl_area, 1); c.gtk_widget_set_focusable(gl_area, 1);
c.gtk_widget_set_focus_on_click(gl_area, 1); c.gtk_widget_set_focus_on_click(gl_area, 1);
// Set up to handle items being dropped on our surface. Files can be dropped
// from Nautilus and strings can be dropped from many programs.
const drop_target = c.gtk_drop_target_new(c.G_TYPE_INVALID, c.GDK_ACTION_COPY);
errdefer c.g_object_unref(drop_target);
var drop_target_types = [_]c.GType{
c.gdk_file_list_get_type(),
c.G_TYPE_STRING,
};
c.gtk_drop_target_set_gtypes(drop_target, @ptrCast(&drop_target_types), drop_target_types.len);
c.gtk_widget_add_controller(@ptrCast(overlay), @ptrCast(drop_target));
// Inherit the parent's font size if we have a parent. // Inherit the parent's font size if we have a parent.
const font_size: ?font.face.DesiredSize = font_size: { const font_size: ?font.face.DesiredSize = font_size: {
if (!app.config.@"window-inherit-font-size") break :font_size null; if (!app.config.@"window-inherit-font-size") break :font_size null;
@ -579,6 +590,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
_ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(&gtkInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(&gtkInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(&gtkInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(&gtkInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(&gtkInputCommit), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(&gtkInputCommit), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(drop_target, "drop", c.G_CALLBACK(&gtkDrop), self, null, c.G_CONNECT_DEFAULT);
} }
fn realize(self: *Surface) !void { fn realize(self: *Surface) !void {
@ -2053,3 +2065,95 @@ pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void {
pub fn toggleSplitZoom(self: *Surface) void { pub fn toggleSplitZoom(self: *Surface) void {
self.setSplitZoom(!self.zoomed_in); self.setSplitZoom(!self.zoomed_in);
} }
/// Handle items being dropped on our surface.
fn gtkDrop(
_: *c.GtkDropTarget,
value: *c.GValue,
x: f64,
y: f64,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
_ = x;
_ = y;
const self = userdataSelf(ud.?);
const alloc = self.app.core_app.alloc;
if (g_value_holds(value, c.G_TYPE_BOXED)) {
var data = std.ArrayList(u8).init(alloc);
defer data.deinit();
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
.child_writer = data.writer(),
};
const writer = shell_escape_writer.writer();
const fl: *c.GdkFileList = @ptrCast(c.g_value_get_boxed(value));
var l = c.gdk_file_list_get_files(fl);
while (l != null) : (l = l.*.next) {
const file: *c.GFile = @ptrCast(l.*.data);
const path = c.g_file_get_path(file) orelse continue;
writer.writeAll(std.mem.span(path)) catch |err| {
log.err("unable to write path to buffer: {}", .{err});
continue;
};
writer.writeAll("\n") catch |err| {
log.err("unable to write to buffer: {}", .{err});
continue;
};
}
const string = data.toOwnedSliceSentinel(0) catch |err| {
log.err("unable to convert to a slice: {}", .{err});
return 1;
};
defer alloc.free(string);
self.doPaste(string);
return 1;
}
if (g_value_holds(value, c.G_TYPE_STRING)) {
if (c.g_value_get_string(value)) |string| {
self.doPaste(std.mem.span(string));
}
return 1;
}
return 1;
}
fn doPaste(self: *Surface, data: [:0]const u8) void {
if (data.len == 0) return;
self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) {
error.UnsafePaste,
error.UnauthorizedPaste,
=> {
ClipboardConfirmationWindow.create(
self.app,
data,
&self.core_surface,
.paste,
) catch |window_err| {
log.err("failed to create clipboard confirmation window err={}", .{window_err});
};
},
error.OutOfMemory,
error.NoSpaceLeft,
=> log.err("failed to complete clipboard request err={}", .{err}),
};
}
/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's
/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it.
fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool {
if (value_) |value| {
if (value.*.g_type == g_type) return true;
return c.g_type_check_value_holds(value, g_type) != 0;
}
return false;
}

View File

@ -21,6 +21,7 @@ pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig"); pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig"); pub const windows = @import("windows.zig");
pub const macos = @import("macos.zig"); pub const macos = @import("macos.zig");
pub const shell = @import("shell.zig");
// Functions and types // Functions and types
pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const CFReleaseThread = @import("cf_release_thread.zig");
@ -48,3 +49,4 @@ pub const open = openpkg.open;
pub const OpenType = openpkg.Type; pub const OpenType = openpkg.Type;
pub const pipe = pipepkg.pipe; pub const pipe = pipepkg.pipe;
pub const resourcesDir = resourcesdir.resourcesDir; pub const resourcesDir = resourcesdir.resourcesDir;
pub const ShellEscapeWriter = shell.ShellEscapeWriter;

95
src/os/shell.zig Normal file
View File

@ -0,0 +1,95 @@
const std = @import("std");
const testing = std.testing;
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.
///
/// T should be a Zig type that follows the `std.io.Writer` interface.
pub fn ShellEscapeWriter(comptime T: type) type {
return struct {
child_writer: T,
fn write(self: *ShellEscapeWriter(T), data: []const u8) error{Error}!usize {
var count: usize = 0;
for (data) |byte| {
const buf = switch (byte) {
'\\',
'"',
'\'',
'$',
'`',
'*',
'?',
' ',
'|',
=> &[_]u8{ '\\', byte },
else => &[_]u8{byte},
};
self.child_writer.writeAll(buf) catch return error.Error;
count += 1;
}
return count;
}
const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write);
pub fn writer(self: *ShellEscapeWriter(T)) Writer {
return .{ .context = self };
}
};
}
test "shell escape 1" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("abc");
try testing.expectEqualStrings("abc", fmt.getWritten());
}
test "shell escape 2" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("a c");
try testing.expectEqualStrings("a\\ c", fmt.getWritten());
}
test "shell escape 3" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("a?c");
try testing.expectEqualStrings("a\\?c", fmt.getWritten());
}
test "shell escape 4" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("a\\c");
try testing.expectEqualStrings("a\\\\c", fmt.getWritten());
}
test "shell escape 5" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("a|c");
try testing.expectEqualStrings("a\\|c", fmt.getWritten());
}
test "shell escape 6" {
var buf: [128]u8 = undefined;
var fmt = std.io.fixedBufferStream(&buf);
var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() };
const writer = shell.writer();
try writer.writeAll("a\"c");
try testing.expectEqualStrings("a\\\"c", fmt.getWritten());
}