diff --git a/include/ghostty.h b/include/ghostty.h index acb6988d6..6c3f5af64 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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, diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 690caac34..074b0f6d5 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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?, location: ghostty_clipboard_e, + content: UnsafePointer?, + len: Int, confirm: Bool ) {} @@ -364,23 +366,53 @@ extension Ghostty { } } - static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { + static func writeClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + content: UnsafePointer?, + 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.. 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 { diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index 11815fbc8..a036f02b4 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -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. diff --git a/macos/Tests/NSPasteboardTests.swift b/macos/Tests/NSPasteboardTests.swift new file mode 100644 index 000000000..d956ce733 --- /dev/null +++ b/macos/Tests/NSPasteboardTests.swift @@ -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) + } +} diff --git a/src/Surface.zig b/src/Surface.zig index c9c40f466..6d5c9b683 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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), } } diff --git a/src/apprt.zig b/src/apprt.zig index 947f29050..dbd62fbfb 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -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; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 617557995..1faa0b9c6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index ac82f941b..009ce018d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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, ); } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index ceea6fee5..2f0a7c5c3 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -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 = {} }); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 646ad5dbd..3f6d64652 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -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( diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 4febebfc6..8c79d6b75 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -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( diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 89b8c2235..bf14b65a9 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -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, diff --git a/src/config/Config.zig b/src/config/Config.zig index 78ea19aef..f8322d5fc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9bdd858c1..26278f386 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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; diff --git a/src/input/command.zig b/src/input/command.zig index 8216d107a..651ba2b30 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -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,