From d5b0ec3770201b27212b66d3ffe85cf2322151bb Mon Sep 17 00:00:00 2001 From: Pedro <157769541+pedrohcmeneses@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:47:13 -0300 Subject: [PATCH 1/2] Paste and copy the whole multi-selection, and extend it with Shift-arrow Multi-selection only served bulk deletion. Return, Command-C and the context-menu Paste and Copy commands now act on every selected clip, in strip order: - Text clips (text, rich text, links, colors) are joined by line breaks into a single paste; formatting is kept when a clip carries RTF. - Neighbouring file clips become one file list. - Images and mixed selections are pasted one clip after another, giving the target app time to read each one. Without direct paste they are written as separate pasteboard items instead. - Paste as Plain Text, Clean Formatting and Markdown join the text form of every selected clip. Shift-arrow extends or shrinks the selection from its anchor like a Finder list; a bare arrow returns to a single selection. selectFirst() now resets the selection anchor too. It used to leave the anchor where the previous presentation ended, so the first Shift-click after reopening the bar selected a range from a clip nobody picked. Claude-Session: https://claude.ai/code/session_01SiCApvjwVcpmBxjvC4aNjB --- README.md | 6 +- Sources/Pesty/AppController.swift | 84 +++++++++-- Sources/Pesty/Monitor/PasteService.swift | 117 ++++++++++++++- Sources/Pesty/Store/ClipboardStore.swift | 47 +++++- Sources/Pesty/UI/ClipCardView.swift | 26 ++-- Sources/Pesty/Util/ClipComposer.swift | 114 ++++++++++++++ Tests/PestyTests/ClipComposerTests.swift | 141 ++++++++++++++++++ Tests/PestyTests/KeyboardSelectionTests.swift | 105 +++++++++++++ 8 files changed, 609 insertions(+), 31 deletions(-) create mode 100644 Sources/Pesty/Util/ClipComposer.swift create mode 100644 Tests/PestyTests/ClipComposerTests.swift create mode 100644 Tests/PestyTests/KeyboardSelectionTests.swift diff --git a/README.md b/README.md index c66c8a7..88e0924 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ The build is signed with a Developer ID and notarized by Apple, so it opens with | `⌘⇧S` (with strip open) | Open Settings | | `⌘⇧P` (with strip open) | Pause / resume clipboard monitoring | | `←` `→` `↑` `↓` | Move selection | -| `return` | Paste selected clip | +| `⇧←` `⇧→` (also `⇧`-click, `⌘`-click) | Extend the selection to several clips | +| `return` | Paste the selection: text clips joined by line breaks, images and mixed selections one clip after another | +| `⌘C` | Copy the selection | | `⌘1`–`⌘9` (configurable) | Quick-paste the Nth clip; add the plain-text modifier (default `⇧`) to paste unformatted | -| `⌫` (search empty) or `⌘⌫` | Delete selected clip | +| `⌫` (search empty) or `⌘⌫` | Delete the selection | | type anything | Search | | `esc` | Clear search, then close | diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index cb6a4a7..b415fd0 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -361,8 +361,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } func pasteSelected() { - guard let item = store.selectedItem else { return } - pasteItem(item) + pasteItems(store.selectedItems) } /// The app that will receive a paste after the floating Pesty panel closes. @@ -390,6 +389,39 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + /// Pastes a whole selection in strip order. `ClipComposer` decides how: + /// text goes out joined in one paste, images and mixed selections one + /// clip after another. + func pasteItems(_ items: [ClipItem], format: PasteFormat = .original) { + guard items.count > 1 else { + if let item = items.first { pasteItem(item, format: format) } + return + } + let steps = ClipComposer.steps(for: items, format: format) + guard !steps.isEmpty else { return } + let target = pasteTarget + hideBar() + let previousChange = NSPasteboard.general.changeCount + PasteService.paste(steps, into: target, monitor: monitor) + if Settings.shared.promoteOnPaste, NSPasteboard.general.changeCount != previousChange { + store.promoteCopiedItems(items) + } + } + + /// What a card's context-menu command acts on: the whole multi-selection + /// when the card is part of it, otherwise that card alone. + func selection(containing item: ClipItem) -> [ClipItem] { + store.multiSelectedIDs.contains(item.id) ? store.selectedItems : [item] + } + + func pasteSelection(containing item: ClipItem, format: PasteFormat = .original) { + pasteItems(selection(containing: item), format: format) + } + + func copySelection(containing item: ClipItem) { + copyItems(selection(containing: item)) + } + #if !MAS /// A direct paste that quietly falls back to a copy reads as "paste is /// broken". Say so once per launch, in the toast rather than a modal: @@ -418,17 +450,36 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { hideBar() } + /// Copies a whole selection in strip order. Text lands as one joined + /// string; anything `ClipComposer` cannot merge is written as separate + /// pasteboard items. + func copyItems(_ items: [ClipItem]) { + guard items.count > 1 else { + if let item = items.first { copyItem(item) } + return + } + let previousChange = NSPasteboard.general.changeCount + let change = PasteService.copy(ClipComposer.steps(for: items)) + monitor.suppressUntilChangeCount = change + if change != previousChange { + store.promoteCopiedItems(items) + copyToast.show(message: "Copied \(items.count) Clips") + } + if Settings.shared.playSoundOnCopy { NSSound(named: "Tink")?.play() } + hideBar() + } + func copySelected() { - guard let item = store.selectedItem else { return } - copyItem(item) + copyItems(store.selectedItems) } - var pasteMenuTitle: String { + func pasteMenuTitle(clipCount: Int = 1) -> String { + let verb = clipCount > 1 ? "Paste \(clipCount) Clips" : "Paste" guard let name = pasteTarget?.localizedName, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return "Paste" + return verb } - return "Paste to \(name)" + return "\(verb) to \(name)" } func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { @@ -604,6 +655,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { let cmd = flags.contains(.command) let ctrl = flags.contains(.control) let opt = flags.contains(.option) + let shift = flags.contains(.shift) if let digit = Self.quickPasteDigit(for: code), includes(Settings.shared.quickPasteModifier, in: flags) { @@ -637,15 +689,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { case kVK_Return, kVK_ANSI_KeypadEnter: pasteSelected(); return nil case kVK_LeftArrow: - if cmd { moveBarSection(by: -1) } else { store.moveSelection(by: -1) } + if cmd { moveBarSection(by: -1) } else { moveBarSelection(by: -1, extending: shift) } return nil case kVK_RightArrow: - if cmd { moveBarSection(by: 1) } else { store.moveSelection(by: 1) } + if cmd { moveBarSection(by: 1) } else { moveBarSelection(by: 1, extending: shift) } return nil case kVK_UpArrow: - store.moveSelection(by: -1); return nil + moveBarSelection(by: -1, extending: shift); return nil case kVK_DownArrow: - store.moveSelection(by: 1); return nil + moveBarSelection(by: 1, extending: shift); return nil case kVK_Delete: if cmd { deleteEffectiveSelection(permanently: opt); return nil } // Backspace edits the query before it can remove a filtered clip, @@ -704,6 +756,16 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { return event } + /// A bare arrow moves the selection and drops any multi-selection; with + /// Shift it extends the selection from its anchor instead. + private func moveBarSelection(by delta: Int, extending: Bool) { + if extending { + store.extendSelection(by: delta) + } else { + store.moveSelection(by: delta) + } + } + /// Cycles the same sources, in the same order, that the tab bar presents. /// The plus button is intentionally excluded: it creates a new pinboard /// rather than representing a navigable section. diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 1cce40e..fc348cf 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -19,6 +19,14 @@ enum PasteService { private static let sourceType = NSPasteboard.PasteboardType("org.nspasteboard.source") private static let packagedBundleID = "com.greycorelabs.pesty" + /// How long the target app gets to read one clip of a multi-clip paste + /// before the next one replaces it on the pasteboard. Nothing reports + /// that a paste was consumed, so this errs on the side of a slow app. + private static let sequenceInterval: TimeInterval = 0.3 + /// Bumped by every paste, so a sequence still in flight stops rather + /// than interleaving with the paste that followed it. + private static var sequenceGeneration = 0 + private static func markSource(of item: ClipItem, on pasteboard: NSPasteboard) { let source = item.sourceBundleID ?? Bundle.main.bundleIdentifier ?? packagedBundleID pasteboard.setString(source, forType: sourceType) @@ -93,10 +101,45 @@ enum PasteService { return pasteboard.changeCount } + /// Writes several clips as separate pasteboard items in one change, for + /// when they cannot be pasted one at a time: a copy, or a paste without + /// direct-paste access. Apps that read every item get them all; the rest + /// take the first. + @discardableResult + static func copy(_ steps: [ClipItem], to pasteboard: NSPasteboard = .general) -> Int { + guard steps.count > 1 else { + return steps.first.map { copy($0, to: pasteboard) } ?? pasteboard.changeCount + } + let writers = steps.flatMap(writers(for:)) + guard !writers.isEmpty else { return pasteboard.changeCount } + pasteboard.clearContents() + pasteboard.writeObjects(writers) + pasteboard.setString(Bundle.main.bundleIdentifier ?? packagedBundleID, forType: sourceType) + return pasteboard.changeCount + } + + private static func writers(for item: ClipItem) -> [NSPasteboardWriting] { + switch item.type { + case .image: + return ClipboardStore.shared.loadImage(for: item).map { [$0] } ?? [] + case .file: + return item.fileURLs.compactMap { URL(string: $0) as NSURL? } + case .richText: + if let rtf = item.rtfData, + let rich = NSAttributedString(rtf: rtf, documentAttributes: nil) { + return [rich] + } + return item.text.map { [$0 as NSString] } ?? [] + case .text, .link, .color: + return item.plainText.map { [$0 as NSString] } ?? [] + } + } + static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, monitor: ClipboardMonitor, format: PasteFormat = .original) { + sequenceGeneration &+= 1 let change = copy(item, format: format) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } @@ -117,19 +160,85 @@ enum PasteService { return } target.activate() - waitForFrontmost(target, attempts: 20) + waitForFrontmost(target, attempts: 20) { sendCommandV() } + #endif + } + + /// Pastes the steps of a multi-clip selection (see `ClipComposer`) in + /// order. One step is an ordinary paste. Several are written and pasted + /// one at a time, which is the only delivery every app understands for + /// text next to images. + static func paste(_ steps: [ClipItem], + into targetApp: NSRunningApplication?, + monitor: ClipboardMonitor) { + guard steps.count > 1 else { + if let only = steps.first { paste(only, into: targetApp, monitor: monitor) } + return + } + sequenceGeneration &+= 1 + if Settings.shared.playSound { NSSound(named: "Pop")?.play() } + + #if !MAS + if Settings.shared.pasteDirectly, AXIsProcessTrusted(), + let target = targetApp, !target.isTerminated { + target.activate() + pasteNext(of: steps[...], into: target, monitor: monitor, generation: sequenceGeneration) + return + } + #endif + + // Without a way to press Command-V between clips, the selection goes + // out as one multi-item pasteboard for the user to paste. + monitor.suppressUntilChangeCount = copy(steps) + guard let target = targetApp, !target.isTerminated else { return } + #if MAS + target.activate() + #else + if Settings.shared.pasteDirectly { AppController.shared.noteBlockedDirectPaste() } #endif } #if !MAS - private static func waitForFrontmost(_ app: NSRunningApplication, attempts: Int) { + private static func pasteNext(of steps: ArraySlice, + into app: NSRunningApplication, + monitor: ClipboardMonitor, + generation: Int) { + guard generation == sequenceGeneration, !app.isTerminated, + let step = steps.first else { return } + let rest = steps.dropFirst() + let previousChange = NSPasteboard.general.changeCount + let change = copy(step) + monitor.suppressUntilChangeCount = change + // A step that wrote nothing (its image file is gone) must not send + // Command-V: the previous clip is still on the pasteboard and would + // be pasted a second time. + guard change != previousChange else { + pasteNext(of: rest, into: app, monitor: monitor, generation: generation) + return + } + // Checked before every step, not just the first: if the user has + // moved to another app, the rest of the selection stays unpasted + // instead of landing there. + waitForFrontmost(app, attempts: 20) { + guard generation == sequenceGeneration else { return } + sendCommandV() + guard !rest.isEmpty else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + sequenceInterval) { + pasteNext(of: rest, into: app, monitor: monitor, generation: generation) + } + } + } + + private static func waitForFrontmost(_ app: NSRunningApplication, + attempts: Int, + then paste: @escaping @MainActor () -> Void) { guard attempts > 0, !app.isTerminated else { return } if NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { sendCommandV() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { paste() } return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - waitForFrontmost(app, attempts: attempts - 1) + waitForFrontmost(app, attempts: attempts - 1, then: paste) } } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 0bb0392..91fab82 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -85,8 +85,14 @@ final class ClipboardStore { /// Settings can report how much space history actually uses. var dataDirectory: URL { baseDir } - private init() { - let base = (Settings.shared.iCloudSync ? ClipboardStore.iCloudBase : nil) ?? ClipboardStore.localBase + private convenience init() { + self.init(baseDirectory: (Settings.shared.iCloudSync ? ClipboardStore.iCloudBase : nil) + ?? ClipboardStore.localBase) + } + + /// The app only ever uses `shared`; tests build a store on a scratch + /// directory so they never read or write the real history. + init(baseDirectory base: URL) { baseDir = base imagesDir = base.appendingPathComponent("images", isDirectory: true) storeURL = base.appendingPathComponent("store.json") @@ -158,6 +164,14 @@ final class ClipboardStore { addCaptured(independentCopy(of: item, createdAt: date)) } + /// Promotes a multi-clip paste or copy as a block: the clips end up at the + /// front of history in the order they were given. + func promoteCopiedItems(_ items: [ClipItem], at date: Date = .now) { + for (offset, item) in items.reversed().enumerated() { + promoteCopiedItem(item, at: date.addingTimeInterval(Double(offset) * 0.001)) + } + } + func applyRetentionPolicy() { trimHistory(); scheduleSave() } func retentionRemovalCount(mode: HistoryRetentionMode, limit: Int, days: Int) -> Int { @@ -517,13 +531,25 @@ final class ClipboardStore { scheduleSave() } - func selectFirst() { selectedID = visibleItems.first?.id } + /// Also resets the anchor: a range extended after the selection jumped to + /// the first clip must start there, not wherever the anchor was left. + func selectFirst() { + selectedID = visibleItems.first?.id + selectionAnchorID = selectedID + } var effectiveSelectionIDs: Set { if !multiSelectedIDs.isEmpty { return multiSelectedIDs } return selectedID.map { [$0] } ?? [] } + /// The effective selection in the order the strip shows it, which is the + /// order a multi-clip paste or copy delivers it in. + var selectedItems: [ClipItem] { + let selection = effectiveSelectionIDs + return visibleItems.filter { selection.contains($0.id) } + } + func isSelected(_ id: UUID) -> Bool { if multiSelectedIDs.isEmpty { return selectedID == id } return multiSelectedIDs.contains(id) @@ -569,6 +595,19 @@ final class ClipboardStore { if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } } + /// Shift-arrow: moves the free end of the selection one clip over while + /// the anchor stays put, so the range grows away from the anchor and + /// shrinks back toward it the way a Finder list does. + func extendSelection(by delta: Int) { + let items = visibleItems + guard !items.isEmpty else { return } + guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { + selectFirst(); return + } + let next = max(0, min(items.count - 1, idx + delta)) + extendSelection(to: items[next].id) + } + func clearMultiSelection() { multiSelectedIDs = [] selectionAnchorID = selectedID @@ -599,7 +638,7 @@ final class ClipboardStore { let items = visibleItems guard !items.isEmpty else { return } guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { - selectedID = items.first?.id; return + selectFirst(); return } let next = max(0, min(items.count - 1, idx + delta)) selectedID = items[next].id diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 7a367a6..9bbff4f 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -295,27 +295,33 @@ struct ClipCardView: View { @ViewBuilder private var menu: some View { - Button { AppController.shared.pasteItem(item) } label: { - Label(AppController.shared.pasteMenuTitle, systemImage: "doc.on.clipboard") + // Paste and Copy act on the whole multi-selection when this card is + // part of it, the same way Delete does. + let selection = AppController.shared.selection(containing: item) + + Button { AppController.shared.pasteSelection(containing: item) } label: { + Label(AppController.shared.pasteMenuTitle(clipCount: selection.count), + systemImage: "doc.on.clipboard") } - Button { AppController.shared.pasteItem(item, format: .plainText) } label: { + Button { AppController.shared.pasteSelection(containing: item, format: .plainText) } label: { Label("Paste as Plain Text", systemImage: "text.alignleft") } - .disabled(item.plainText == nil) + .disabled(!selection.contains { $0.plainText != nil }) - Button { AppController.shared.pasteItem(item, format: .cleanFormatting) } label: { + Button { AppController.shared.pasteSelection(containing: item, format: .cleanFormatting) } label: { Label("Paste with Clean Formatting", systemImage: "paintbrush") } - .disabled(!FormatConverter.canConvert(item)) + .disabled(!selection.contains(where: FormatConverter.canConvert)) - Button { AppController.shared.pasteItem(item, format: .markdown) } label: { + Button { AppController.shared.pasteSelection(containing: item, format: .markdown) } label: { Label("Paste as Markdown", systemImage: "number") } - .disabled(!FormatConverter.canConvert(item)) + .disabled(!selection.contains(where: FormatConverter.canConvert)) - Button { AppController.shared.copyItem(item) } label: { - Label("Copy", systemImage: "doc.on.doc") + Button { AppController.shared.copySelection(containing: item) } label: { + Label(selection.count > 1 ? "Copy \(selection.count) Clips" : "Copy", + systemImage: "doc.on.doc") } Divider() diff --git a/Sources/Pesty/Util/ClipComposer.swift b/Sources/Pesty/Util/ClipComposer.swift new file mode 100644 index 0000000..36f103f --- /dev/null +++ b/Sources/Pesty/Util/ClipComposer.swift @@ -0,0 +1,114 @@ +import AppKit + +/// Turns a multi-clip selection into the pasteboard writes that deliver it. +/// +/// Neighbouring clips that fit in one write are merged: text joins with a +/// line break and files become one file list, so each run is a single paste. +/// An image always stands alone. A result with more than one step has to be +/// pasted one step at a time, because no single pasteboard write carries +/// text and images in a form every app accepts. +enum ClipComposer { + static let separator = "\n" + + private enum Kind { + case text, file, image + } + + /// A clip's text, with its formatting when it is to be kept. + private struct Fragment { + var text: String + var rtf: Data? + } + + /// The writes that deliver `items`, in order. The converting formats + /// always produce one text step and leave out clips with no text form. + static func steps(for items: [ClipItem], format: PasteFormat = .original) -> [ClipItem] { + switch format { + case .original: + return originalSteps(for: items) + case .plainText: + return [textStep(items.compactMap { item in + item.plainText.map { Fragment(text: $0) } + })].compactMap { $0 } + case .cleanFormatting: + return [textStep(items.compactMap { item in + item.plainText.map { Fragment(text: $0, rtf: FormatConverter.cleanedRTF(for: item)) } + })].compactMap { $0 } + case .markdown: + return [textStep(items.compactMap { item in + item.plainText.map { Fragment(text: FormatConverter.markdown(for: item) ?? $0) } + })].compactMap { $0 } + } + } + + private static func kind(of item: ClipItem) -> Kind { + switch item.type { + case .text, .richText, .link, .color: return .text + case .file: return .file + case .image: return .image + } + } + + private static func originalSteps(for items: [ClipItem]) -> [ClipItem] { + var runs: [(kind: Kind, items: [ClipItem])] = [] + for item in items { + let kind = kind(of: item) + if kind != .image, runs.last?.kind == kind { + runs[runs.count - 1].items.append(item) + } else { + runs.append((kind, [item])) + } + } + return runs.enumerated().compactMap { index, run in + let follows = index > 0 + let precedes = index < runs.count - 1 + // A lone clip with nothing to join or separate goes out untouched. + if run.items.count == 1, run.kind != .text || (!follows && !precedes) { + return run.items[0] + } + switch run.kind { + case .image: + return run.items[0] + case .file: + return ClipItem(type: .file, + text: run.items.compactMap(\.text).joined(separator: ", "), + fileURLs: run.items.flatMap(\.fileURLs)) + case .text: + // Pasted one after another, text would run straight into the + // image or file next to it; a line break keeps them apart. + return textStep(run.items.compactMap { item in + item.plainText.map { Fragment(text: $0, rtf: item.rtfData) } + }, leadingBreak: follows, trailingBreak: precedes) + } + } + } + + /// One text clip out of several. It stays plain unless a fragment brings + /// formatting, in which case the plain fragments ride along in the + /// system font. + private static func textStep(_ fragments: [Fragment], + leadingBreak: Bool = false, + trailingBreak: Bool = false) -> ClipItem? { + guard !fragments.isEmpty else { return nil } + let text = (leadingBreak ? separator : "") + + fragments.map(\.text).joined(separator: separator) + + (trailingBreak ? separator : "") + guard fragments.contains(where: { $0.rtf != nil }) else { + return ClipItem(type: .text, text: text) + } + + let plain: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + let lineBreak = NSAttributedString(string: separator, attributes: plain) + let rich = NSMutableAttributedString() + if leadingBreak { rich.append(lineBreak) } + for (index, fragment) in fragments.enumerated() { + if index > 0 { rich.append(lineBreak) } + rich.append(fragment.rtf.flatMap { NSAttributedString(rtf: $0, documentAttributes: nil) } + ?? NSAttributedString(string: fragment.text, attributes: plain)) + } + if trailingBreak { rich.append(lineBreak) } + return ClipItem(type: .richText, + text: text, + rtfData: rich.rtf(from: NSRange(location: 0, length: rich.length))) + } +} diff --git a/Tests/PestyTests/ClipComposerTests.swift b/Tests/PestyTests/ClipComposerTests.swift new file mode 100644 index 0000000..375dfca --- /dev/null +++ b/Tests/PestyTests/ClipComposerTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import Pesty + +@MainActor +final class ClipComposerTests: XCTestCase { + private func boldRTF(_ string: String) -> Data { + let attributed = NSAttributedString( + string: string, + attributes: [.font: NSFont.boldSystemFont(ofSize: 13)]) + return attributed.rtf(from: NSRange(location: 0, length: attributed.length))! + } + + func testTextClipsJoinIntoOnePlainStep() { + let steps = ClipComposer.steps(for: [ + ClipItem(type: .text, text: "first"), + ClipItem(type: .link, text: "https://example.com"), + ClipItem(type: .color, colorHex: "#5B8DEF") + ]) + + XCTAssertEqual(steps.count, 1) + XCTAssertEqual(steps.first?.type, .text) + XCTAssertEqual(steps.first?.text, "first\nhttps://example.com\n#5B8DEF") + XCTAssertNil(steps.first?.rtfData) + } + + func testFormattingSurvivesNextToPlainText() throws { + let steps = ClipComposer.steps(for: [ + ClipItem(type: .richText, text: "bold", rtfData: boldRTF("bold")), + ClipItem(type: .text, text: "plain") + ]) + + XCTAssertEqual(steps.count, 1) + XCTAssertEqual(steps.first?.type, .richText) + XCTAssertEqual(steps.first?.text, "bold\nplain") + let rich = try XCTUnwrap(steps.first?.rtfData.flatMap { + NSAttributedString(rtf: $0, documentAttributes: nil) + }) + XCTAssertEqual(rich.string, "bold\nplain") + let traits: (Int) -> NSFontDescriptor.SymbolicTraits = { index in + (rich.attribute(.font, at: index, effectiveRange: nil) as? NSFont)? + .fontDescriptor.symbolicTraits ?? [] + } + XCTAssertTrue(traits(0).contains(.bold)) + XCTAssertFalse(traits(5).contains(.bold)) + } + + func testMixedSelectionPastesInStepsWithTextSetApart() { + let image = ClipItem(type: .image, imageFileName: "shot.png") + let steps = ClipComposer.steps(for: [ + ClipItem(type: .text, text: "one"), + ClipItem(type: .text, text: "two"), + image, + ClipItem(type: .text, text: "three") + ]) + + XCTAssertEqual(steps.map(\.type), [.text, .image, .text]) + XCTAssertEqual(steps[0].text, "one\ntwo\n") + XCTAssertEqual(steps[1], image) + XCTAssertEqual(steps[2].text, "\nthree") + } + + func testImagesNeverMerge() { + let first = ClipItem(type: .image, imageFileName: "a.png") + let second = ClipItem(type: .image, imageFileName: "b.png") + + XCTAssertEqual(ClipComposer.steps(for: [first, second]), [first, second]) + } + + func testNeighbouringFilesBecomeOneFileList() { + let report = ClipItem(type: .file, text: "report.pdf", fileURLs: ["file:///tmp/report.pdf"]) + let notes = ClipItem(type: .file, text: "notes.txt", fileURLs: ["file:///tmp/notes.txt"]) + let image = ClipItem(type: .image, imageFileName: "shot.png") + + let together = ClipComposer.steps(for: [report, notes]) + XCTAssertEqual(together.count, 1) + XCTAssertEqual(together.first?.type, .file) + XCTAssertEqual(together.first?.fileURLs, ["file:///tmp/report.pdf", "file:///tmp/notes.txt"]) + XCTAssertEqual(together.first?.text, "report.pdf, notes.txt") + + XCTAssertEqual(ClipComposer.steps(for: [report, image, notes]), [report, image, notes]) + } + + func testPlainTextKeepsOnlyWhatHasText() { + let items = [ + ClipItem(type: .richText, text: "bold", rtfData: boldRTF("bold")), + ClipItem(type: .image, imageFileName: "shot.png"), + ClipItem(type: .file, text: "report.pdf", fileURLs: ["file:///tmp/report.pdf"]) + ] + + let steps = ClipComposer.steps(for: items, format: .plainText) + XCTAssertEqual(steps.count, 1) + XCTAssertEqual(steps.first?.type, .text) + XCTAssertEqual(steps.first?.text, "bold\n/tmp/report.pdf") + XCTAssertNil(steps.first?.rtfData) + + let images = [ClipItem(type: .image, imageFileName: "a.png"), + ClipItem(type: .image, imageFileName: "b.png")] + XCTAssertTrue(ClipComposer.steps(for: images, format: .plainText).isEmpty) + } + + func testMarkdownConvertsEachClipAndFallsBackToItsText() { + let steps = ClipComposer.steps(for: [ + ClipItem(type: .richText, text: "bold", rtfData: boldRTF("bold")), + ClipItem(type: .text, text: "plain") + ], format: .markdown) + + XCTAssertEqual(steps.count, 1) + XCTAssertEqual(steps.first?.text, "**bold**\nplain") + } + + func testSeveralStepsAreCopiedAsSeparatePasteboardItems() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("pesty.tests.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + let steps = [ + ClipItem(type: .text, text: "caption\n"), + ClipItem(type: .file, text: "report.pdf", fileURLs: ["file:///tmp/report.pdf"]) + ] + + let before = pasteboard.changeCount + XCTAssertNotEqual(PasteService.copy(steps, to: pasteboard), before) + + let items = pasteboard.pasteboardItems ?? [] + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items.first?.string(forType: .string), "caption\n") + XCTAssertEqual(items.last?.string(forType: .fileURL), "file:///tmp/report.pdf") + } + + func testOneStepIsCopiedLikeAnyOtherClip() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("pesty.tests.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + let steps = ClipComposer.steps(for: [ + ClipItem(type: .text, text: "one"), + ClipItem(type: .text, text: "two") + ]) + + PasteService.copy(steps, to: pasteboard) + + XCTAssertEqual(pasteboard.pasteboardItems?.count, 1) + XCTAssertEqual(pasteboard.string(forType: .string), "one\ntwo") + } +} diff --git a/Tests/PestyTests/KeyboardSelectionTests.swift b/Tests/PestyTests/KeyboardSelectionTests.swift new file mode 100644 index 0000000..b7c1119 --- /dev/null +++ b/Tests/PestyTests/KeyboardSelectionTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import Pesty + +@MainActor +final class KeyboardSelectionTests: XCTestCase { + /// A store on a scratch directory holding five clips, with the bar just + /// presented: `clips` is in strip order and the first one is selected. + private func makeStore() -> (store: ClipboardStore, clips: [ClipItem]) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("PestyTests-\(UUID().uuidString)", isDirectory: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + + let store = ClipboardStore(baseDirectory: directory) + let clips = ["a", "b", "c", "d", "e"].map { ClipItem(type: .text, text: $0) } + for clip in clips.reversed() { store.addCaptured(clip) } + store.prepareForBarPresentation() + return (store, clips) + } + + private func ids(_ clips: ArraySlice) -> Set { + Set(clips.map(\.id)) + } + + func testShiftArrowGrowsThenShrinksBackToTheAnchor() { + let (store, clips) = makeStore() + + store.extendSelection(by: 1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[0...1])) + store.extendSelection(by: 1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[0...2])) + XCTAssertEqual(store.selectedID, clips[2].id) + + store.extendSelection(by: -1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[0...1])) + store.extendSelection(by: -1) + XCTAssertTrue(store.multiSelectedIDs.isEmpty) + XCTAssertEqual(store.effectiveSelectionIDs, [clips[0].id]) + } + + func testShiftArrowCrossesTheAnchor() { + let (store, clips) = makeStore() + store.moveSelection(by: 2) + + store.extendSelection(by: -1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[1...2])) + store.extendSelection(by: 1) + XCTAssertTrue(store.multiSelectedIDs.isEmpty) + XCTAssertEqual(store.selectedID, clips[2].id) + store.extendSelection(by: 1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[2...3])) + } + + func testShiftArrowStopsAtTheEndsOfTheStrip() { + let (store, clips) = makeStore() + + store.extendSelection(by: -1) + XCTAssertTrue(store.multiSelectedIDs.isEmpty) + XCTAssertEqual(store.selectedID, clips[0].id) + + for _ in 0..<10 { store.extendSelection(by: 1) } + XCTAssertEqual(store.multiSelectedIDs, ids(clips[0...4])) + XCTAssertEqual(store.selectedID, clips[4].id) + } + + func testPlainArrowReturnsToASingleSelection() { + let (store, clips) = makeStore() + store.extendSelection(by: 1) + store.extendSelection(by: 1) + + store.moveSelection(by: 1) + XCTAssertTrue(store.multiSelectedIDs.isEmpty) + XCTAssertEqual(store.selectedID, clips[3].id) + + // The clip the arrow landed on is the new anchor. + store.extendSelection(by: -1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[2...3])) + } + + func testAnchorStartsOverWhenTheBarIsPresentedAgain() { + let (store, clips) = makeStore() + store.moveSelection(by: 3) + + store.prepareForBarPresentation() + store.extendSelection(by: 1) + XCTAssertEqual(store.multiSelectedIDs, ids(clips[0...1])) + } + + func testSelectedItemsComeInStripOrderNotClickOrder() { + let (store, clips) = makeStore() + + store.toggleSelection(clips[3].id) + store.toggleSelection(clips[1].id) + XCTAssertEqual(store.selectedItems.map(\.id), [clips[0].id, clips[1].id, clips[3].id]) + + store.clearMultiSelection() + XCTAssertEqual(store.selectedItems.map(\.id), [clips[1].id]) + } + + func testPromotingASelectionKeepsItsOrderAtTheFront() { + let (store, clips) = makeStore() + + store.promoteCopiedItems([clips[2], clips[4]]) + XCTAssertEqual(store.history.map(\.text), ["c", "e", "a", "b", "d"]) + } +} From 29251686ca2bc027ebbf938e950e77d51d3e34f6 Mon Sep 17 00:00:00 2001 From: Pedro <157769541+pedrohcmeneses@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:01:52 -0300 Subject: [PATCH 2/2] Open the bar on the cards the first time it is shown The first time a panel becomes key, AppKit makes its only key view, the search field, first responder. showBar() resigns the field before presenting, but on the first presentation the panel does not exist yet, so the bar opened with the query focused until it was hidden once. Return and the plain arrows still worked through the field's delegate, which hid the problem; Command-C, Command-arrow and Shift-arrow were taken as edits to an empty query and did nothing. Resign the field right after the panel becomes key. Claude-Session: https://claude.ai/code/session_01SiCApvjwVcpmBxjvC4aNjB --- Sources/Pesty/UI/BarWindowController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 69eaa06..b84d13e 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -175,6 +175,11 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) + // The first time a panel becomes key, AppKit hands first responder to + // its only key view: the search field. The bar always opens on the + // cards, so take it back; left there, Command-C, Command-arrow and + // Shift-arrow edit an empty query instead of acting on the strip. + searchBridge.resign() let finish: @MainActor @Sendable () -> Void = { [weak self] in guard let self else { return }