Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
84 changes: 73 additions & 11 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 113 additions & 4 deletions Sources/Pesty/Monitor/PasteService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() }
Expand All @@ -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<ClipItem>,
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)
}
}

Expand Down
47 changes: 43 additions & 4 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UUID> {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading