From 1b1ca3936c574dbb9b7830ce82680cbcbdfc8c33 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 02:38:48 -0700 Subject: [PATCH] WIP: add numbered quick paste --- README.md | 1 + docs/SMOKE_TEST.md | 5 +- .../views/ClipboardPanelController.swift | 35 +++++++++++++- .../clipbored/views/ClipboardPanelView.swift | 39 +++++++++++++++- .../views/ClipboardPanelViewModel.swift | 12 +++++ .../ClipboardPanelControllerTests.swift | 41 ++++++++++++----- .../ClipboardPanelViewModelTests.swift | 46 +++++++++++++++++++ .../ClipboardPanelViewTests.swift | 12 +++++ 8 files changed, 175 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a72230b..84de7da 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca - Global shortcuts: - `Command + Option + V` toggles the clipboard panel - `Command + ,` opens settings + - `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads - Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 7453c59..0916e85 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -50,8 +50,9 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 4. Select an audio item and paste into an app that accepts sound pasteboard data. 5. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data. 6. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field. -7. Without Accessibility permission, confirm paste actions copy and show the permission fallback status. -8. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. +7. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only. +8. Without Accessibility permission, confirm paste actions copy and show the permission fallback status. +9. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. ## Settings diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 7892c5e..0c09fb6 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -52,6 +52,17 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel private var isAnimating = false private var quickLookURL: URL? private var screenParametersObserver: NSObjectProtocol? + private static let quickPasteKeyCodes: [UInt16: Int] = [ + 18: 0, + 19: 1, + 20: 2, + 21: 3, + 23: 4, + 22: 5, + 26: 6, + 28: 7, + 25: 8 + ] private static let collectionShortcuts: [UInt16: ClipboardSortMode] = [ 18: .mostRecent, 19: .mostUsed, @@ -326,6 +337,16 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel removeKeyMonitor() keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } + if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), + let index = Self.quickPasteIndex(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { + self.viewModel.pasteItem(at: index) + return nil + } + if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), + let index = Self.quickPastePlainTextIndex(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { + self.viewModel.pasteItemPlainText(at: index) + return nil + } if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), let mode = Self.collectionShortcutMode(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { self.viewModel.sortMode = mode @@ -424,9 +445,21 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel || NSApp.window(withWindowNumber: event.windowNumber) === panel } - static func collectionShortcutMode(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardSortMode? { + static func quickPasteIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) guard relevantModifiers == .command else { return nil } + return quickPasteKeyCodes[keyCode] + } + + static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? { + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + guard relevantModifiers == [.command, .shift] else { return nil } + return quickPasteKeyCodes[keyCode] + } + + static func collectionShortcutMode(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardSortMode? { + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + guard relevantModifiers == [.command, .option] else { return nil } return collectionShortcuts[keyCode] } diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index b1b2c22..80b114a 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -990,6 +990,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.map(\.debugHeaderBadgeSymbol) } + var debugQuickPasteBadgeTexts: [String] { + cardViews.compactMap(\.debugQuickPasteBadgeText) + } + var debugSelectedCardFrameInDocument: NSRect { guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardViews.count else { return .zero @@ -1399,6 +1403,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var actionRailButtons: [NSButton] = [] private weak var headerBadgeView: NSView? private weak var headerPinView: NSView? + private weak var quickPasteBadgeLabel: NSTextField? private var isSelected = false private var isHovered = false private var mouseDownLocation: NSPoint? @@ -1564,6 +1569,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var debugHeaderBadgeIsHidden: Bool { headerBadgeView?.isHidden ?? false } + + var debugQuickPasteBadgeText: String? { + quickPasteBadgeLabel?.stringValue + } #endif private func contextMenu() -> NSMenu { @@ -1959,11 +1968,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { titleAndSource.spacing = 2 titleAndSource.translatesAutoresizingMaskIntoConstraints = false - let labelStack = NSStackView(views: [titleAndSource]) + var labelViews: [NSView] = [] + if let quickPasteBadge = quickPasteBadge() { + labelViews.append(quickPasteBadge) + } + labelViews.append(titleAndSource) + let labelStack = NSStackView(views: labelViews) labelStack.orientation = .horizontal labelStack.alignment = .centerY labelStack.distribution = .fill - labelStack.spacing = 1 + labelStack.spacing = labelViews.count > 1 ? 9 : 1 labelStack.translatesAutoresizingMaskIntoConstraints = false let badge = iconBadge(for: item) @@ -2011,6 +2025,27 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return header } + private func quickPasteBadge() -> NSTextField? { + guard index < 9 else { return nil } + let label = NSTextField(labelWithString: "\(index + 1)") + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .bold) + label.textColor = NSColor.white.withAlphaComponent(0.92) + label.alignment = .center + label.lineBreakMode = .byClipping + label.wantsLayer = true + label.layer?.cornerRadius = 9 + label.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor + label.layer?.borderWidth = 0.5 + label.layer?.borderColor = NSColor.white.withAlphaComponent(0.24).cgColor + label.toolTip = "Press Command-\(index + 1) to paste" + label.setAccessibilityLabel("Quick paste \(index + 1)") + label.translatesAutoresizingMaskIntoConstraints = false + label.widthAnchor.constraint(equalToConstant: 19).isActive = true + label.heightAnchor.constraint(equalToConstant: 19).isActive = true + quickPasteBadgeLabel = label + return label + } + private func bodyView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView { let body = NSView() body.wantsLayer = true diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 41b66d3..3af3c5d 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -218,6 +218,18 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: result.message) } + func pasteItem(at index: Int) { + guard index >= 0 && index < visibleItems.count else { return } + selectItem(at: index) + pasteSelected() + } + + func pasteItemPlainText(at index: Int) { + guard index >= 0 && index < visibleItems.count else { return } + selectItem(at: index) + pasteSelectedPlainText() + } + func copySelected() { guard let item = selectedItem else { return } let result = pasteService.copy(item) diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index 0563d32..b2d4457 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -128,20 +128,39 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(inset, 18) } - func testCommandNumberShortcutsMapToCollections() { - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command), .mostRecent) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: .command), .mostUsed) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: .command), .text) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: .command), .links) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: .command), .images) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: .command), .files) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: .command), .pinned) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: .command), .audio) + func testCommandNumberShortcutsMapToQuickPasteSlots() { + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7) + XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8) } - func testCollectionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() { + func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() { + XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0) + XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8) + XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command)) + XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift])) + } + + func testCommandOptionNumberShortcutsMapToCollections() { + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio) + } + + func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() { XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [])) - XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .shift])) + XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command)) XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command)) } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 532fa9d..796f626 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -395,6 +395,52 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(store.items.first?.useCount, 1) } + func testQuickPasteItemByVisibleIndexWritesThatCard() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("first visible quick paste", createdAt: Date(timeIntervalSince1970: 200))) + store.upsert(makeTextItem("second visible quick paste", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 2) + NSPasteboard.general.clearContents() + + viewModel.pasteItem(at: 1) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "second visible quick paste") + XCTAssertEqual(viewModel.statusMessage, "Copied") + } + + func testQuickPastePlainTextByVisibleIndexOmitsRichPasteboardTypes() { + 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/quick", + payloadHash: hash("https://example.com/quick"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: "Safari", + imagePath: nil, + thumbnailPath: nil + ) + store.upsert(item) + store.flushPersistenceForTesting() + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + NSPasteboard.general.clearContents() + + viewModel.pasteItemPlainText(at: 0) + + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://example.com/quick") + XCTAssertNil(NSPasteboard.general.string(forType: .URL)) + } + func testStackPastesQueuedItemsInOrderAndConsumesThem() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 3fb7142..3c17d3f 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -59,6 +59,18 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) } + func testCardsShowQuickPasteNumberBadgesForFirstNineItems() { + let fixture = makePanelFixture() + for index in 0..<10 { + fixture.store.upsert(makeTextItem("Quick paste badge \(index)", store: fixture.store)) + drainMainQueue() + } + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugVisibleCardCount, 10) + XCTAssertEqual(fixture.view.debugQuickPasteBadgeTexts, ["1", "2", "3", "4", "5", "6", "7", "8", "9"]) + } + func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store))