From 475e387f7fe53a9f0a0071a5441fe10b77176069 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 02:04:12 -0700 Subject: [PATCH] WIP: add plain text paste mode --- .../services/PasteActionService.swift | 74 ++++++++++++++++ .../views/ClipboardPanelController.swift | 24 +++++ .../clipbored/views/ClipboardPanelView.swift | 31 +++++++ .../views/ClipboardPanelViewModel.swift | 25 ++++++ .../ClipboardPanelControllerTests.swift | 11 +++ .../ClipboardPanelViewModelTests.swift | 33 +++++++ .../ClipboardPanelViewTests.swift | 2 +- .../PasteActionServiceTests.swift | 88 +++++++++++++++++++ 8 files changed, 287 insertions(+), 1 deletion(-) diff --git a/sources/clipbored/services/PasteActionService.swift b/sources/clipbored/services/PasteActionService.swift index 633e9e0..c50086d 100644 --- a/sources/clipbored/services/PasteActionService.swift +++ b/sources/clipbored/services/PasteActionService.swift @@ -25,18 +25,27 @@ final class PasteActionService { enum PasteActionResult: Equatable { case pasted + case pastedPlainText case copied + case copiedPlainText case copiedNeedsPermission + case copiedPlainTextNeedsPermission case failed(String) var message: String { switch self { case .pasted: return "Pasted" + case .pastedPlainText: + return "Pasted Plain Text" case .copied: return "Copied" + case .copiedPlainText: + return "Copied Plain Text" case .copiedNeedsPermission: return "Copied. Grant Accessibility access to paste automatically." + case .copiedPlainTextNeedsPermission: + return "Copied Plain Text. Grant Accessibility access to paste automatically." case .failed(let message): return message } @@ -67,11 +76,40 @@ final class PasteActionService { return .pasted } + func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult { + guard writePlainTextToPasteboard(item) else { + return .failed("Could not write plain text to clipboard.") + } + + guard let targetApp, + !targetApp.isTerminated else { + return .copiedPlainText + } + + guard accessibilityPermissionProvider() else { + return .copiedPlainTextNeedsPermission + } + + guard targetActivator(targetApp) else { + return .copiedPlainText + } + + keyboardPasteScheduler { [weak self] in + self?.pasteViaKeyboard() + } + return .pastedPlainText + } + @discardableResult func copy(_ item: ClipboardItem) -> PasteActionResult { writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.") } + @discardableResult + func copyPlainText(_ item: ClipboardItem) -> PasteActionResult { + writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.") + } + func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] { switch item.kind { case .image: @@ -185,6 +223,37 @@ final class PasteActionService { return didWrite } + func plainText(for item: ClipboardItem) -> String? { + switch item.kind { + case .text, .unknown: + return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText) + case .url, .file: + return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText) + case .richText: + if let data = cacheService.data(for: item.payload), + let text = richTextPlainString(from: data) { + return text + } + return nonEmptyPlainText(richTextFallbackPlainString(for: item)) + case .image: + return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText) + case .pdf, .audio: + return nonEmptyPlainText(item.displayText) + } + } + + @discardableResult + func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool { + guard let text = plainText(for: item) else { return false } + let board = NSPasteboard.general + board.clearContents() + let didWrite = board.setString(text, forType: .string) + if didWrite { + ClipboardSelfWriteTracker.mark(changeCount: board.changeCount) + } + return didWrite + } + private func stringPasteboardItem(_ value: String) -> NSPasteboardItem { let pasteboardItem = NSPasteboardItem() pasteboardItem.setString(value, forType: .string) @@ -220,6 +289,11 @@ final class PasteActionService { return normalized } + private func nonEmptyPlainText(_ value: String?) -> String? { + guard let value, !value.clipboardTrimmed.isEmpty else { return nil } + return value + } + private func richTextPlainString(from data: Data) -> String? { guard let attributed = NSAttributedString(rtf: data, documentAttributes: nil) else { return nil diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index d49fb5a..415d01a 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -15,7 +15,9 @@ struct ClipboardPanelReflowPlan { enum ClipboardPanelShortcutAction: Equatable { case copy + case copyPlainText case open + case pastePlainText case preview case reveal } @@ -332,6 +334,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel self.performShortcutAction(action) return nil } + if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), + let action = Self.modifiedShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { + self.performShortcutAction(action) + return nil + } guard self.shouldHandlePanelKeyEvent(event) else { return event } switch event.keyCode { case 53: @@ -365,8 +372,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel switch action { case .copy: viewModel.copySelected() + case .copyPlainText: + viewModel.copySelectedPlainText() case .open: viewModel.openSelected() + case .pastePlainText: + viewModel.pasteSelectedPlainText() case .preview: previewSelected() case .reveal: @@ -430,6 +441,19 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } } + static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? { + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + guard relevantModifiers == [.command, .shift] else { return nil } + switch keyCode { + case 8: + return .copyPlainText + case 9: + return .pastePlainText + default: + return nil + } + } + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { quickLookURL == nil ? 0 : 1 } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index a7a6227..77e203d 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -528,6 +528,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.copySelected() } + card.onPastePlainText = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.pasteSelectedPlainText() + } + card.onCopyPlainText = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.copySelectedPlainText() + } card.onEditText = { [weak self] selected in self?.editText(at: selected) } @@ -1308,6 +1316,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onSelect: (Int) -> Void = { _ in } var onPaste: (Int) -> Void = { _ in } var onCopy: (Int) -> Void = { _ in } + var onPastePlainText: (Int) -> Void = { _ in } + var onCopyPlainText: (Int) -> Void = { _ in } var onEditText: (Int) -> Void = { _ in } var onPreview: (Int) -> Void = { _ in } var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } @@ -1491,6 +1501,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { menu.autoenablesItems = false addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu) addMenuItem("Copy", action: #selector(copyFromMenu), to: menu) + if canPlainText { + addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu) + addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), to: menu) + } if canEditText { addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu) } @@ -1584,6 +1598,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { itemKind == .text } + private var canPlainText: Bool { + switch itemKind { + case .url, .image, .richText, .file, .pdf, .audio: + return true + case .text, .unknown: + return false + } + } + private var canReveal: Bool { switch itemKind { case .file, .image, .pdf, .audio: @@ -1694,6 +1717,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onCopy(index) } + @objc private func pastePlainTextFromMenu() { + onPastePlainText(index) + } + + @objc private func copyPlainTextFromMenu() { + onCopyPlainText(index) + } + @objc private func editTextFromMenu() { onEditText(index) } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index a7d8143..ed8e5aa 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -152,6 +152,20 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: result.message) } + func pasteSelectedPlainText() { + guard let item = selectedItem else { return } + let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider()) + if case .pastedPlainText = result { + willPasteToTarget() + } + if case .failed = result {} else { + store.markUsed(item.id) + selectedItemID = item.id + } + statusMessage = result.message + settings.setPasteStatus(message: result.message) + } + func copySelected() { guard let item = selectedItem else { return } let result = pasteService.copy(item) @@ -163,6 +177,17 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: result.message) } + func copySelectedPlainText() { + guard let item = selectedItem else { return } + let result = pasteService.copyPlainText(item) + if case .failed = result {} else { + store.markUsed(item.id) + selectedItemID = item.id + } + statusMessage = result.message + settings.setPasteStatus(message: result.message) + } + func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { guard index >= 0 && index < visibleItems.count else { return [] } return pasteService.pasteboardWriters(for: visibleItems[index]) diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index 21e865e..e3df0ac 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -157,4 +157,15 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift])) XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command)) } + + func testModifiedShortcutsMapToPlainTextActions() { + XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText) + XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) + } + + func testModifiedShortcutsRequireCommandShiftOnly() { + XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command)) + XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift])) + XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift])) + } } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 1b364f4..694db1d 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -271,6 +271,39 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload) } + func testCopySelectedPlainTextWritesOnlyStringRepresentation() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let item = ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Example", + payload: "https://example.com", + payloadHash: hash("https://example.com"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + store.upsert(item) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + + viewModel.copySelectedPlainText() + store.flushPersistenceForTesting() + + XCTAssertEqual(viewModel.statusMessage, "Copied Plain Text") + XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload) + XCTAssertNil(NSPasteboard.general.string(forType: .URL)) + XCTAssertEqual(store.items.first?.id, item.id) + XCTAssertEqual(store.items.first?.useCount, 1) + } + func testUpdateSelectedTextRefreshesVisibleItemAndSearch() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 20b986b..3c0f567 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -349,7 +349,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] + ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"] ) } diff --git a/tests/clipboredtests/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index 4d459ba..cc1fdbe 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -124,6 +124,41 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Paste into target") } + func testAutomaticPlainTextPasteActivatesTargetAndSchedulesKeyboardPasteWhenPermissionGranted() throws { + var activatedProcessID: pid_t? + let targetApp = try makeRunningTargetApp() + var didScheduleKeyboardPaste = false + let service = PasteActionService( + accessibilityPermissionProvider: { true }, + targetActivator: { app in + activatedProcessID = app.processIdentifier + return true + }, + keyboardPasteScheduler: { _ in + didScheduleKeyboardPaste = true + } + ) + let item = ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Apple", + payload: "https://apple.com", + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual(service.pastePlainText(item, targetApp: targetApp), .pastedPlainText) + XCTAssertEqual(activatedProcessID, targetApp.processIdentifier) + XCTAssertTrue(didScheduleKeyboardPaste) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://apple.com") + XCTAssertNil(NSPasteboard.general.string(forType: .URL)) + } + func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws { var didAttemptActivation = false let targetApp = try makeRunningTargetApp() @@ -264,6 +299,37 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .string), attributed.string) } + func testCopyPlainTextStripsRichTextFormatting() throws { + let directory = try makeTempDirectory() + let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService()) + let attributed = NSAttributedString( + string: "Styled clipboard text", + attributes: [.font: NSFont.boldSystemFont(ofSize: 15)] + ) + let rtfData = try XCTUnwrap( + attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:]) + ) + let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID())) + let service = PasteActionService(cacheService: cacheService) + let item = ClipboardItem( + id: UUID(), + kind: .richText, + displayText: attributed.string, + payload: path, + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual(service.copyPlainText(item), .copiedPlainText) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), attributed.string) + XCTAssertNil(NSPasteboard.general.data(forType: .rtf)) + } + func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() { let service = PasteActionService() let item = ClipboardItem( @@ -307,6 +373,28 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Readable rich text") } + func testCopyPlainTextForURLOmitsURLPasteboardTypes() { + let service = PasteActionService() + let item = ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Apple", + payload: "https://apple.com", + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual(service.copyPlainText(item), .copiedPlainText) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://apple.com") + XCTAssertNil(NSPasteboard.general.string(forType: .URL)) + XCTAssertNil(NSPasteboard.general.string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name"))) + } + func testCopyWritesFileReferenceType() throws { let fileURL = try makeTempFile(contents: "file contents") let service = PasteActionService()