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/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 } 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"]) + } +}