Copy formatted text to clipboard with plain, make it configurable (#9418)

Fixes #9397 

This makes `copy_to_clipboard` take an optional parameter with the
format to copy. **The default has changed to `mixed`,** which will set
multiple content types on the clipboard allowing the OS or target
application to choose what they prefer. In this case, we set both
`text/plain` and `text/html`.

This only includes the macOS implementation. The GTK side still needs to
be done, but is likely trivial to do.


https://github.com/user-attachments/assets/b1b2f5cd-d59a-496e-bb77-86a60571ed7f
pull/9423/head
Mitchell Hashimoto 2025-10-30 15:17:16 -07:00 committed by GitHub
commit bc0f5e4d57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 359 additions and 72 deletions

View File

@ -45,6 +45,11 @@ typedef enum {
GHOSTTY_CLIPBOARD_SELECTION,
} ghostty_clipboard_e;
typedef struct {
const char *mime;
const char *data;
} ghostty_clipboard_content_s;
typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
@ -855,8 +860,9 @@ typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
void*,
ghostty_clipboard_request_e);
typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
const char*,
ghostty_clipboard_e,
const ghostty_clipboard_content_s*,
size_t,
bool);
typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,

View File

@ -61,7 +61,8 @@ extension Ghostty {
action_cb: { app, target, action in App.action(app!, target: target, action: action) },
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) },
write_clipboard_cb: { userdata, loc, content, len, confirm in
App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) },
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }
)
@ -276,8 +277,9 @@ extension Ghostty {
static func writeClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
location: ghostty_clipboard_e,
content: UnsafePointer<ghostty_clipboard_content_s>?,
len: Int,
confirm: Bool
) {}
@ -364,23 +366,53 @@ extension Ghostty {
}
}
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
static func writeClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
content: UnsafePointer<ghostty_clipboard_content_s>?,
len: Int,
confirm: Bool
) {
let surface = self.surfaceUserdata(from: userdata)
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
guard let content = content, len > 0 else { return }
// Convert the C array to Swift array
let contentArray = (0..<len).compactMap { i in
Ghostty.ClipboardContent.from(content: content[i])
}
guard !contentArray.isEmpty else { return }
// Assert there is only one text/plain entry. For security reasons we need
// to guarantee this for now since our confirmation dialog only shows one.
assert(contentArray.filter({ $0.mime == "text/plain" }).count <= 1,
"clipboard contents should have at most one text/plain entry")
if !confirm {
pasteboard.declareTypes([.string], owner: nil)
pasteboard.setString(valueStr, forType: .string)
// Declare all types
let types = contentArray.compactMap { item in
NSPasteboard.PasteboardType(mimeType: item.mime)
}
pasteboard.declareTypes(types, owner: nil)
// Set data for each type
for item in contentArray {
guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue }
pasteboard.setString(item.data, forType: type)
}
return
}
// For confirmation, use the text/plain content if it exists
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else {
return
}
NotificationCenter.default.post(
name: Notification.confirmClipboard,
object: surface,
userInfo: [
Notification.ConfirmClipboardStrKey: valueStr,
Notification.ConfirmClipboardStrKey: textPlainContent.data,
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
]
)

View File

@ -299,6 +299,23 @@ extension Ghostty {
}
}
}
struct ClipboardContent {
let mime: String
let data: String
static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? {
guard let mimePtr = content.mime,
let dataPtr = content.data else {
return nil
}
return ClipboardContent(
mime: String(cString: mimePtr),
data: String(cString: dataPtr)
)
}
}
/// macos-icon
enum MacOSIcon: String {

View File

@ -1,5 +1,30 @@
import AppKit
import GhosttyKit
import UniformTypeIdentifiers
extension NSPasteboard.PasteboardType {
/// Initialize a pasteboard type from a MIME type string
init?(mimeType: String) {
// Explicit mappings for common MIME types
switch mimeType {
case "text/plain":
self = .string
return
default:
break
}
// Try to get UTType from MIME type
guard let utType = UTType(mimeType: mimeType) else {
// Fallback: use the MIME type directly as identifier
self.init(mimeType)
return
}
// Use the UTType's identifier
self.init(utType.identifier)
}
}
extension NSPasteboard {
/// The pasteboard to used for Ghostty selection.

View File

@ -0,0 +1,33 @@
//
// NSPasteboardTests.swift
// GhosttyTests
//
// Tests for NSPasteboard.PasteboardType MIME type conversion.
//
import Testing
import AppKit
@testable import Ghostty
struct NSPasteboardTypeExtensionTests {
/// Test text/plain MIME type converts to .string
@Test func testTextPlainMimeType() async throws {
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/plain")
#expect(pasteboardType != nil)
#expect(pasteboardType == .string)
}
/// Test text/html MIME type converts to .html
@Test func testTextHtmlMimeType() async throws {
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html")
#expect(pasteboardType != nil)
#expect(pasteboardType == .html)
}
/// Test image/png MIME type
@Test func testImagePngMimeType() async throws {
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png")
#expect(pasteboardType != nil)
#expect(pasteboardType == .png)
}
}

View File

@ -1945,7 +1945,10 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard)
// them to confirm the clipboard access. Each app runtime handles this
// differently.
const confirm = self.config.clipboard_write == .ask;
self.rt_surface.setClipboardString(buf, loc, confirm) catch |err| {
self.rt_surface.setClipboard(loc, &.{.{
.mime = "text/plain",
.data = buf,
}}, confirm) catch |err| {
log.err("error setting clipboard string err={}", .{err});
return;
};
@ -1955,19 +1958,101 @@ fn copySelectionToClipboards(
self: *Surface,
sel: terminal.Selection,
clipboards: []const apprt.Clipboard,
) void {
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
.trim = self.config.clipboard_trim_trailing_spaces,
}) catch |err| {
log.err("error reading selection string err={}", .{err});
return;
};
defer self.alloc.free(buf);
format: input.Binding.Action.CopyToClipboard,
) !void {
// Create an arena to simplify memory management here.
var arena = ArenaAllocator.init(self.alloc);
errdefer arena.deinit();
const alloc = arena.allocator();
for (clipboards) |clipboard| self.rt_surface.setClipboardString(
buf,
// The options we'll use for all formatting. We'll just override the
// emit format.
const opts: terminal.formatter.Options = .{
.emit = .plain, // We'll override this below
.unwrap = true,
.trim = self.config.clipboard_trim_trailing_spaces,
.background = self.io.terminal.colors.background.get(),
.foreground = self.io.terminal.colors.foreground.get(),
.palette = &self.io.terminal.colors.palette.current,
};
const ScreenFormatter = terminal.formatter.ScreenFormatter;
var aw: std.Io.Writer.Allocating = .init(alloc);
var contents: std.ArrayList(apprt.ClipboardContent) = .empty;
switch (format) {
.plain => {
var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts);
formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer);
try contents.append(alloc, .{
.mime = "text/plain",
.data = try aw.toOwnedSliceSentinel(0),
});
},
.vt => {
var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: {
var copy = opts;
copy.emit = .vt;
break :opts copy;
});
formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer);
try contents.append(alloc, .{
.mime = "text/plain",
.data = try aw.toOwnedSliceSentinel(0),
});
},
.html => {
var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: {
var copy = opts;
copy.emit = .html;
break :opts copy;
});
formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer);
try contents.append(alloc, .{
.mime = "text/html",
.data = try aw.toOwnedSliceSentinel(0),
});
},
.mixed => {
var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts);
formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer);
try contents.append(alloc, .{
.mime = "text/plain",
.data = try aw.toOwnedSliceSentinel(0),
});
assert(aw.written().len == 0);
formatter = .init(&self.io.terminal.screen, opts: {
var copy = opts;
copy.emit = .html;
// We purposely don't emit background/foreground for mixed
// mode because the HTML contents is often used for rich text
// input and with trimmed spaces it looks pretty bad.
copy.background = null;
copy.foreground = null;
break :opts copy;
});
formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer);
try contents.append(alloc, .{
.mime = "text/html",
.data = try aw.toOwnedSliceSentinel(0),
});
},
}
assert(contents.items.len > 0);
for (clipboards) |clipboard| self.rt_surface.setClipboard(
clipboard,
contents.items,
false,
) catch |err| {
log.err(
@ -1998,9 +2083,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
.false => unreachable, // handled above with an early exit
// Both standard and selection clipboards are set.
.clipboard => {
self.copySelectionToClipboards(sel, &.{ .standard, .selection });
},
.clipboard => try self.copySelectionToClipboards(
sel,
&.{ .standard, .selection },
.mixed,
),
// The selection clipboard is set if supported, otherwise the standard.
.true => {
@ -2008,7 +2095,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
.selection
else
.standard;
self.copySelectionToClipboards(sel, &.{clipboard});
try self.copySelectionToClipboards(
sel,
&.{clipboard},
.mixed,
);
},
}
}
@ -3746,14 +3837,22 @@ pub fn mouseButtonCallback(
},
.copy => {
if (self.io.terminal.screen.selection) |sel| {
self.copySelectionToClipboards(sel, &.{.standard});
try self.copySelectionToClipboards(
sel,
&.{.standard},
.mixed,
);
}
try self.setSelection(null);
try self.queueRender();
},
.@"copy-or-paste" => if (self.io.terminal.screen.selection) |sel| {
self.copySelectionToClipboards(sel, &.{.standard});
try self.copySelectionToClipboards(
sel,
&.{.standard},
.mixed,
);
try self.setSelection(null);
try self.queueRender();
} else {
@ -4657,23 +4756,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
self.renderer_state.terminal.fullReset();
},
.copy_to_clipboard => {
.copy_to_clipboard => |format| {
// We can read from the renderer state without holding
// the lock because only we will write to this field.
if (self.io.terminal.screen.selection) |sel| {
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
.trim = self.config.clipboard_trim_trailing_spaces,
}) catch |err| {
log.err("error reading selection string err={}", .{err});
return true;
};
defer self.alloc.free(buf);
self.rt_surface.setClipboardString(buf, .standard, false) catch |err| {
log.err("error setting clipboard string err={}", .{err});
return true;
};
try self.copySelectionToClipboards(
sel,
&.{.standard},
format,
);
// Clear the selection if configured to do so.
if (self.config.selection_clear_on_copy) {
@ -4721,7 +4812,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
defer self.alloc.free(url_text);
self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| {
self.rt_surface.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = url_text,
}}, false) catch |err| {
log.err("error copying url to clipboard err={}", .{err});
return false;
};
@ -4736,7 +4830,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
const title = self.rt_surface.getTitle() orelse return false;
if (title.len == 0) return false;
self.rt_surface.setClipboardString(title, .standard, false) catch |err| {
self.rt_surface.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = title,
}}, false) catch |err| {
log.err("error copying title to clipboard err={}", .{err});
return true;
};
@ -5273,7 +5370,10 @@ fn writeScreenFile(
.copy => {
const pathZ = try self.alloc.dupeZ(u8, path);
defer self.alloc.free(pathZ);
try self.rt_surface.setClipboardString(pathZ, .standard, false);
try self.rt_surface.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = pathZ,
}}, false);
},
.open => try self.openUrl(.{ .kind = .text, .url = path }),
.paste => self.io.queueMessage(try termio.Message.writeReq(
@ -5313,11 +5413,10 @@ pub fn completeClipboardRequest(
confirmed,
),
.osc_52_write => |clipboard| try self.rt_surface.setClipboardString(
data,
clipboard,
!confirmed,
),
.osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{
.mime = "text/plain",
.data = data,
}}, !confirmed),
}
}

View File

@ -28,6 +28,7 @@ pub const Target = action.Target;
pub const ContentScale = structs.ContentScale;
pub const Clipboard = structs.Clipboard;
pub const ClipboardContent = structs.ClipboardContent;
pub const ClipboardRequest = structs.ClipboardRequest;
pub const ClipboardRequestType = structs.ClipboardRequestType;
pub const ColorScheme = structs.ColorScheme;

View File

@ -66,7 +66,13 @@ pub const App = struct {
) callconv(.c) void,
/// Write the clipboard value.
write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.c) void,
write_clipboard: *const fn (
SurfaceUD,
c_int,
[*]const CAPI.ClipboardContent,
usize,
bool,
) callconv(.c) void,
/// Close the current surface given by this function.
close_surface: ?*const fn (SurfaceUD, bool) callconv(.c) void = null,
@ -699,16 +705,27 @@ pub const Surface = struct {
alloc.destroy(state);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *const Surface,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) !void {
const alloc = self.app.core_app.alloc;
const array = try alloc.alloc(CAPI.ClipboardContent, contents.len);
defer alloc.free(array);
for (contents, 0..) |content, i| {
array[i] = .{
.mime = content.mime,
.data = content.data,
};
}
self.app.opts.write_clipboard(
self.userdata,
val.ptr,
@intCast(@intFromEnum(clipboard_type)),
array.ptr,
array.len,
confirm,
);
}
@ -1211,6 +1228,12 @@ pub const CAPI = struct {
cell_height_px: u32,
};
// ghostty_clipboard_content_s
const ClipboardContent = extern struct {
mime: [*:0]const u8,
data: [*:0]const u8,
};
// ghostty_text_s
const Text = extern struct {
tl_px_x: f64,

View File

@ -80,15 +80,15 @@ pub fn clipboardRequest(
);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) !void {
self.surface.setClipboardString(
val,
self.surface.setClipboard(
clipboard_type,
contents,
confirm,
);
}

View File

@ -1111,7 +1111,7 @@ pub const Application = extern struct {
self.syncActionAccelerator("win.split-down", .{ .new_split = .down });
self.syncActionAccelerator("win.split-left", .{ .new_split = .left });
self.syncActionAccelerator("win.split-up", .{ .new_split = .up });
self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} });
self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = .mixed });
self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} });
self.syncActionAccelerator("win.reset", .{ .reset = {} });
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });

View File

@ -1553,16 +1553,16 @@ pub const Surface = extern struct {
);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) void {
Clipboard.set(
self,
val,
clipboard_type,
contents,
confirm,
);
}
@ -3334,12 +3334,19 @@ const Clipboard = struct {
/// Set the clipboard contents.
pub fn set(
self: *Surface,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) void {
const priv = self.private();
// For GTK, we only support text/plain type to set strings currently.
const val: [:0]const u8 = for (contents) |content| {
if (std.mem.eql(u8, content.mime, "text/plain")) {
break content.data;
}
} else return;
// If no confirmation is necessary, set the clipboard.
if (!confirm) {
const clipboard = get(

View File

@ -1801,7 +1801,7 @@ pub const Window = extern struct {
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.copy_to_clipboard);
self.performBindingAction(.{ .copy_to_clipboard = .mixed });
}
fn actionPaste(

View File

@ -54,6 +54,11 @@ pub const Clipboard = enum(Backing) {
};
};
pub const ClipboardContent = struct {
mime: [:0]const u8,
data: [:0]const u8,
};
pub const ClipboardRequestType = enum(u8) {
paste,
osc_52_read,

View File

@ -5651,12 +5651,12 @@ pub const Keybinds = struct {
try self.set.put(
alloc,
.{ .key = .{ .physical = .copy } },
.{ .copy_to_clipboard = {} },
.{ .copy_to_clipboard = .mixed },
);
try self.set.put(
alloc,
.{ .key = .{ .physical = .paste } },
.{ .paste_from_clipboard = {} },
.paste_from_clipboard,
);
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
@ -5669,7 +5669,7 @@ pub const Keybinds = struct {
try self.set.put(
alloc,
.{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } },
.{ .copy_to_clipboard = {} },
.{ .copy_to_clipboard = .mixed },
);
try self.set.put(
alloc,
@ -5688,7 +5688,7 @@ pub const Keybinds = struct {
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'c' }, .mods = mods },
.{ .copy_to_clipboard = {} },
.{ .copy_to_clipboard = .mixed },
.{ .performable = true },
);
try self.set.put(

View File

@ -296,7 +296,7 @@ pub const Action = union(enum) {
reset,
/// Copy the selected text to the clipboard.
copy_to_clipboard,
copy_to_clipboard: CopyToClipboard,
/// Paste the contents of the default clipboard.
paste_from_clipboard,
@ -889,6 +889,19 @@ pub const Action = union(enum) {
u16,
};
pub const CopyToClipboard = enum {
plain,
vt,
html,
/// This type will mix multiple distinct types with a set content-type
/// such as text/html for html, so that the OS/application can choose
/// what is best when pasting.
mixed,
pub const default: CopyToClipboard = .mixed;
};
pub const WriteScreenAction = enum {
copy,
paste,
@ -3239,6 +3252,28 @@ test "parse: set_font_size" {
}
}
test "parse: copy to clipboard default" {
const testing = std.testing;
// parameter
{
const binding = try parseSingle("a=copy_to_clipboard");
try testing.expect(binding.action == .copy_to_clipboard);
try testing.expectEqual(Action.CopyToClipboard.mixed, binding.action.copy_to_clipboard);
}
}
test "parse: copy to clipboard explicit" {
const testing = std.testing;
// parameter
{
const binding = try parseSingle("a=copy_to_clipboard:html");
try testing.expect(binding.action == .copy_to_clipboard);
try testing.expectEqual(Action.CopyToClipboard.html, binding.action.copy_to_clipboard);
}
}
test "action: format" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -121,11 +121,15 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Reset the terminal to a clean state.",
}},
.copy_to_clipboard => comptime &.{.{
.action = .copy_to_clipboard,
.copy_to_clipboard => comptime &.{ .{
.action = .{ .copy_to_clipboard = .mixed },
.title = "Copy to Clipboard",
.description = "Copy the selected text to the clipboard.",
}},
}, .{
.action = .{ .copy_to_clipboard = .html },
.title = "Copy HTML to Clipboard",
.description = "Copy the selected text as HTML to the clipboard.",
} },
.copy_url_to_clipboard => comptime &.{.{
.action = .copy_url_to_clipboard,