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:
- `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

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.
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

View File

@@ -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]
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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()

View File

@@ -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))