Skip to content

Commit bc0f5e4

Browse files
authored
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
2 parents 77b038d + 5c1f036 commit bc0f5e4

File tree

16 files changed

+357
-70
lines changed

16 files changed

+357
-70
lines changed

‎include/ghostty.h‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ typedef enum {
4545
GHOSTTY_CLIPBOARD_SELECTION,
4646
} ghostty_clipboard_e;
4747

48+
typedef struct {
49+
const char *mime;
50+
const char *data;
51+
} ghostty_clipboard_content_s;
52+
4853
typedef enum {
4954
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
5055
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
@@ -855,8 +860,9 @@ typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
855860
void*,
856861
ghostty_clipboard_request_e);
857862
typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
858-
const char*,
859863
ghostty_clipboard_e,
864+
const ghostty_clipboard_content_s*,
865+
size_t,
860866
bool);
861867
typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
862868
typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,

‎macos/Sources/Ghostty/Ghostty.App.swift‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ extension Ghostty {
6161
action_cb: { app, target, action in App.action(app!, target: target, action: action) },
6262
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
6363
confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
64-
write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) },
64+
write_clipboard_cb: { userdata, loc, content, len, confirm in
65+
App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) },
6566
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }
6667
)
6768

@@ -276,8 +277,9 @@ extension Ghostty {
276277

277278
static func writeClipboard(
278279
_ userdata: UnsafeMutableRawPointer?,
279-
string: UnsafePointer<CChar>?,
280280
location: ghostty_clipboard_e,
281+
content: UnsafePointer<ghostty_clipboard_content_s>?,
282+
len: Int,
281283
confirm: Bool
282284
) {}
283285

@@ -364,23 +366,53 @@ extension Ghostty {
364366
}
365367
}
366368

367-
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
369+
static func writeClipboard(
370+
_ userdata: UnsafeMutableRawPointer?,
371+
location: ghostty_clipboard_e,
372+
content: UnsafePointer<ghostty_clipboard_content_s>?,
373+
len: Int,
374+
confirm: Bool
375+
) {
368376
let surface = self.surfaceUserdata(from: userdata)
369-
370-
371377
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
372-
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
378+
guard let content = content, len > 0 else { return }
379+
380+
// Convert the C array to Swift array
381+
let contentArray = (0..<len).compactMap { i in
382+
Ghostty.ClipboardContent.from(content: content[i])
383+
}
384+
guard !contentArray.isEmpty else { return }
385+
386+
// Assert there is only one text/plain entry. For security reasons we need
387+
// to guarantee this for now since our confirmation dialog only shows one.
388+
assert(contentArray.filter({ $0.mime == "text/plain" }).count <= 1,
389+
"clipboard contents should have at most one text/plain entry")
390+
373391
if !confirm {
374-
pasteboard.declareTypes([.string], owner: nil)
375-
pasteboard.setString(valueStr, forType: .string)
392+
// Declare all types
393+
let types = contentArray.compactMap { item in
394+
NSPasteboard.PasteboardType(mimeType: item.mime)
395+
}
396+
pasteboard.declareTypes(types, owner: nil)
397+
398+
// Set data for each type
399+
for item in contentArray {
400+
guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue }
401+
pasteboard.setString(item.data, forType: type)
402+
}
376403
return
377404
}
378405

406+
// For confirmation, use the text/plain content if it exists
407+
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else {
408+
return
409+
}
410+
379411
NotificationCenter.default.post(
380412
name: Notification.confirmClipboard,
381413
object: surface,
382414
userInfo: [
383-
Notification.ConfirmClipboardStrKey: valueStr,
415+
Notification.ConfirmClipboardStrKey: textPlainContent.data,
384416
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
385417
]
386418
)

‎macos/Sources/Ghostty/Package.swift‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ extension Ghostty {
299299
}
300300
}
301301
}
302+
303+
struct ClipboardContent {
304+
let mime: String
305+
let data: String
306+
307+
static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? {
308+
guard let mimePtr = content.mime,
309+
let dataPtr = content.data else {
310+
return nil
311+
}
312+
313+
return ClipboardContent(
314+
mime: String(cString: mimePtr),
315+
data: String(cString: dataPtr)
316+
)
317+
}
318+
}
302319

303320
/// macos-icon
304321
enum MacOSIcon: String {

‎macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
import AppKit
22
import GhosttyKit
3+
import UniformTypeIdentifiers
4+
5+
extension NSPasteboard.PasteboardType {
6+
/// Initialize a pasteboard type from a MIME type string
7+
init?(mimeType: String) {
8+
// Explicit mappings for common MIME types
9+
switch mimeType {
10+
case "text/plain":
11+
self = .string
12+
return
13+
default:
14+
break
15+
}
16+
17+
// Try to get UTType from MIME type
18+
guard let utType = UTType(mimeType: mimeType) else {
19+
// Fallback: use the MIME type directly as identifier
20+
self.init(mimeType)
21+
return
22+
}
23+
24+
// Use the UTType's identifier
25+
self.init(utType.identifier)
26+
}
27+
}
328

429
extension NSPasteboard {
530
/// The pasteboard to used for Ghostty selection.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// NSPasteboardTests.swift
3+
// GhosttyTests
4+
//
5+
// Tests for NSPasteboard.PasteboardType MIME type conversion.
6+
//
7+
8+
import Testing
9+
import AppKit
10+
@testable import Ghostty
11+
12+
struct NSPasteboardTypeExtensionTests {
13+
/// Test text/plain MIME type converts to .string
14+
@Test func testTextPlainMimeType() async throws {
15+
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/plain")
16+
#expect(pasteboardType != nil)
17+
#expect(pasteboardType == .string)
18+
}
19+
20+
/// Test text/html MIME type converts to .html
21+
@Test func testTextHtmlMimeType() async throws {
22+
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html")
23+
#expect(pasteboardType != nil)
24+
#expect(pasteboardType == .html)
25+
}
26+
27+
/// Test image/png MIME type
28+
@Test func testImagePngMimeType() async throws {
29+
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png")
30+
#expect(pasteboardType != nil)
31+
#expect(pasteboardType == .png)
32+
}
33+
}

0 commit comments

Comments
 (0)