WIP: add numbered quick paste

This commit is contained in:
Akshay Kolli
2026-06-30 02:38:48 -07:00
parent 158086fdc9
commit 1b1ca3936c
8 changed files with 175 additions and 16 deletions

View File

@@ -11,6 +11,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- Global shortcuts: - Global shortcuts:
- `Command + Option + V` toggles the clipboard panel - `Command + Option + V` toggles the clipboard panel
- `Command + ,` opens settings - `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 - 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 - 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 - Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images

View File

@@ -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. 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. 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. 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. 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. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. 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 ## Settings

View File

@@ -52,6 +52,17 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
private var isAnimating = false private var isAnimating = false
private var quickLookURL: URL? private var quickLookURL: URL?
private var screenParametersObserver: NSObjectProtocol? 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] = [ private static let collectionShortcuts: [UInt16: ClipboardSortMode] = [
18: .mostRecent, 18: .mostRecent,
19: .mostUsed, 19: .mostUsed,
@@ -326,6 +337,16 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
removeKeyMonitor() removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event } 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), if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
let mode = Self.collectionShortcutMode(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { let mode = Self.collectionShortcutMode(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
self.viewModel.sortMode = mode self.viewModel.sortMode = mode
@@ -424,9 +445,21 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|| NSApp.window(withWindowNumber: event.windowNumber) === panel || 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) let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers == .command else { return nil } 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] return collectionShortcuts[keyCode]
} }

View File

@@ -990,6 +990,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardViews.map(\.debugHeaderBadgeSymbol) cardViews.map(\.debugHeaderBadgeSymbol)
} }
var debugQuickPasteBadgeTexts: [String] {
cardViews.compactMap(\.debugQuickPasteBadgeText)
}
var debugSelectedCardFrameInDocument: NSRect { var debugSelectedCardFrameInDocument: NSRect {
guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardViews.count else { guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardViews.count else {
return .zero return .zero
@@ -1399,6 +1403,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var actionRailButtons: [NSButton] = [] private var actionRailButtons: [NSButton] = []
private weak var headerBadgeView: NSView? private weak var headerBadgeView: NSView?
private weak var headerPinView: NSView? private weak var headerPinView: NSView?
private weak var quickPasteBadgeLabel: NSTextField?
private var isSelected = false private var isSelected = false
private var isHovered = false private var isHovered = false
private var mouseDownLocation: NSPoint? private var mouseDownLocation: NSPoint?
@@ -1564,6 +1569,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var debugHeaderBadgeIsHidden: Bool { var debugHeaderBadgeIsHidden: Bool {
headerBadgeView?.isHidden ?? false headerBadgeView?.isHidden ?? false
} }
var debugQuickPasteBadgeText: String? {
quickPasteBadgeLabel?.stringValue
}
#endif #endif
private func contextMenu() -> NSMenu { private func contextMenu() -> NSMenu {
@@ -1959,11 +1968,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
titleAndSource.spacing = 2 titleAndSource.spacing = 2
titleAndSource.translatesAutoresizingMaskIntoConstraints = false 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.orientation = .horizontal
labelStack.alignment = .centerY labelStack.alignment = .centerY
labelStack.distribution = .fill labelStack.distribution = .fill
labelStack.spacing = 1 labelStack.spacing = labelViews.count > 1 ? 9 : 1
labelStack.translatesAutoresizingMaskIntoConstraints = false labelStack.translatesAutoresizingMaskIntoConstraints = false
let badge = iconBadge(for: item) let badge = iconBadge(for: item)
@@ -2011,6 +2025,27 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return header 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 { private func bodyView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView {
let body = NSView() let body = NSView()
body.wantsLayer = true body.wantsLayer = true

View File

@@ -218,6 +218,18 @@ final class ClipboardPanelViewModel {
settings.setPasteStatus(message: result.message) 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() { func copySelected() {
guard let item = selectedItem else { return } guard let item = selectedItem else { return }
let result = pasteService.copy(item) let result = pasteService.copy(item)

View File

@@ -128,20 +128,39 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(inset, 18) XCTAssertEqual(inset, 18)
} }
func testCommandNumberShortcutsMapToCollections() { func testCommandNumberShortcutsMapToQuickPasteSlots() {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command), .mostRecent) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: .command), .mostUsed) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: .command), .text) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: .command), .links) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: .command), .images) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: .command), .files) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: .command), .pinned) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: .command), .audio) 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: []))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .shift])) XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command)) XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
} }

View File

@@ -395,6 +395,52 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(store.items.first?.useCount, 1) 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() { func testStackPastesQueuedItemsInOrderAndConsumesThem() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -59,6 +59,18 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) 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() { func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store)) fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store))