From 2f9b0c8ebd149353129bbcae9e7c7f8265a06680 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:56:44 -0700 Subject: [PATCH 1/6] Add Paste Stack core workflow --- Sources/Pesty/AppController.swift | 51 +++++++ Sources/Pesty/Hotkey/HotKeyCenter.swift | 41 ++++-- Sources/Pesty/Monitor/ClipboardMonitor.swift | 3 +- Sources/Pesty/Monitor/PasteService.swift | 11 +- .../Pesty/Settings/HotkeyRecorderView.swift | 14 +- Sources/Pesty/Settings/Settings.swift | 20 +++ Sources/Pesty/Settings/SettingsView.swift | 15 +- Sources/Pesty/Store/ClipboardStore.swift | 6 +- Sources/Pesty/Store/PasteSequence.swift | 57 ++++++++ Sources/Pesty/UI/BarView.swift | 14 ++ Sources/Pesty/UI/PasteStackView.swift | 130 ++++++++++++++++++ .../Pesty/UI/PasteStackWindowController.swift | 47 +++++++ 12 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 Sources/Pesty/Store/PasteSequence.swift create mode 100644 Sources/Pesty/UI/PasteStackView.swift create mode 100644 Sources/Pesty/UI/PasteStackWindowController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..a4f2a26 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -8,14 +8,17 @@ final class AppController: NSObject, NSApplicationDelegate { let store = ClipboardStore.shared let monitor = ClipboardMonitor() + let pasteSequence = PasteSequence.shared private var barController: BarWindowController? private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? + private var pasteStackController: PasteStackWindowController? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? + private var pasteStackTargetApp: NSRunningApplication? var suppressAutoHide = false @@ -29,6 +32,7 @@ final class AppController: NSObject, NSApplicationDelegate { monitor.start() HotKeyCenter.shared.onTrigger = { [weak self] in self?.toggleBar() } + HotKeyCenter.shared.onSequenceTrigger = { [weak self] in self?.pasteNextInSequence() } HotKeyCenter.shared.start() setupStatusItem() @@ -167,6 +171,53 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func beginPasteSequence() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.begin() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func showPasteStack() { + if pasteStackController == nil { + pasteStackController = PasteStackWindowController() + } + pasteStackController?.show() + } + + func cancelPasteSequence() { + pasteSequence.cancel() + pasteStackController?.hide() + pasteStackTargetApp = nil + } + + func capturePasteStackItem(_ item: ClipItem) { + _ = pasteSequence.addIfNeeded(item) + } + + func pasteNextInSequence() { + guard pasteSequence.hasEntries else { return } + + #if !MAS + guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } + #endif + + guard let entry = pasteSequence.next() else { return } + PasteService.paste(entry.item, + into: pasteStackTargetApp ?? previousApp, + monitor: monitor, + imageOverride: entry.imagePreview) + if !pasteSequence.hasEntries { + pasteStackController?.hide() + pasteStackTargetApp = nil + } + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { diff --git a/Sources/Pesty/Hotkey/HotKeyCenter.swift b/Sources/Pesty/Hotkey/HotKeyCenter.swift index 1014327..14a4522 100644 --- a/Sources/Pesty/Hotkey/HotKeyCenter.swift +++ b/Sources/Pesty/Hotkey/HotKeyCenter.swift @@ -6,8 +6,10 @@ final class HotKeyCenter { static let shared = HotKeyCenter() var onTrigger: (() -> Void)? + var onSequenceTrigger: (() -> Void)? private var hotKeyRef: EventHotKeyRef? + private var sequenceHotKeyRef: EventHotKeyRef? private var handlerRef: EventHandlerRef? private let signature: OSType = 0x50535459 @@ -22,25 +24,48 @@ final class HotKeyCenter { guard handlerRef == nil else { return } var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed)) - InstallEventHandler(GetApplicationEventTarget(), { _, _, _ -> OSStatus in - DispatchQueue.main.async { HotKeyCenter.shared.onTrigger?() } + InstallEventHandler(GetApplicationEventTarget(), { _, event, _ -> OSStatus in + var id = EventHotKeyID() + GetEventParameter(event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &id) + DispatchQueue.main.async { + if id.id == 2 { + HotKeyCenter.shared.onSequenceTrigger?() + } else { + HotKeyCenter.shared.onTrigger?() + } + } return noErr }, 1, &spec, nil, &handlerRef) } func reload() { unregister() - let keyCode = UInt32(Settings.shared.hotkeyKeyCode) - let modifiers = UInt32(Settings.shared.hotkeyModifiers) - guard keyCode != 0 else { return } - let id = EventHotKeyID(signature: signature, id: 1) + hotKeyRef = register(keyCode: Settings.shared.hotkeyKeyCode, + modifiers: Settings.shared.hotkeyModifiers, + id: 1) + sequenceHotKeyRef = register(keyCode: Settings.shared.sequenceHotkeyKeyCode, + modifiers: Settings.shared.sequenceHotkeyModifiers, + id: 2) + } + + private func register(keyCode: Int, modifiers: Int, id: UInt32) -> EventHotKeyRef? { + guard keyCode != 0 else { return nil } + let hotKeyID = EventHotKeyID(signature: signature, id: id) var ref: EventHotKeyRef? - let status = RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &ref) - if status == noErr { hotKeyRef = ref } + let status = RegisterEventHotKey(UInt32(keyCode), UInt32(modifiers), hotKeyID, + GetApplicationEventTarget(), 0, &ref) + return status == noErr ? ref : nil } private func unregister() { if let ref = hotKeyRef { UnregisterEventHotKey(ref); hotKeyRef = nil } + if let ref = sequenceHotKeyRef { UnregisterEventHotKey(ref); sequenceHotKeyRef = nil } } static func describe(keyCode: Int, modifiers: Int) -> String { diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..95aa4ac 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -30,7 +30,8 @@ final class ClipboardMonitor { lastChangeCount = current if current == suppressUntilChangeCount { return } guard let item = makeItem() else { return } - ClipboardStore.shared.addCaptured(item) + let storedItem = ClipboardStore.shared.addCaptured(item) + AppController.shared.capturePasteStackItem(storedItem) } private func makeItem() -> ClipItem? { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..33d6228 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,9 +5,11 @@ import Carbon.HIToolbox enum PasteService { @discardableResult - static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int { + static func copy(_ item: ClipItem, + to pasteboard: NSPasteboard = .general, + imageOverride: NSImage? = nil) -> Int { if item.type == .image { - guard let img = ClipboardStore.shared.loadImage(for: item) else { + guard let img = imageOverride ?? ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount } pasteboard.clearContents() @@ -38,8 +40,9 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + imageOverride: NSImage? = nil) { + let change = copy(item, imageOverride: imageOverride) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } diff --git a/Sources/Pesty/Settings/HotkeyRecorderView.swift b/Sources/Pesty/Settings/HotkeyRecorderView.swift index 056c223..8060e90 100644 --- a/Sources/Pesty/Settings/HotkeyRecorderView.swift +++ b/Sources/Pesty/Settings/HotkeyRecorderView.swift @@ -3,15 +3,21 @@ import AppKit import Carbon.HIToolbox struct HotkeyRecorderView: View { - @Bindable private var settings = Settings.shared + @Binding private var keyCode: Int + @Binding private var modifiers: Int @State private var recording = false @State private var monitor: Any? + init(keyCode: Binding, modifiers: Binding) { + _keyCode = keyCode + _modifiers = modifiers + } + var body: some View { Button { recording ? stop() : start() } label: { - Text(recording ? "Press keys…" : settings.hotkeyDisplay) + Text(recording ? "Press keys…" : HotKeyCenter.describe(keyCode: keyCode, modifiers: modifiers)) .font(.system(size: 13, weight: .medium, design: .rounded)) .frame(minWidth: 90) .padding(.horizontal, 12).padding(.vertical, 5) @@ -34,8 +40,8 @@ struct HotkeyRecorderView: View { if mods & (cmdKey | controlKey | optionKey) == 0 { NSSound.beep(); return nil } - settings.hotkeyKeyCode = Int(event.keyCode) - settings.hotkeyModifiers = mods + keyCode = Int(event.keyCode) + modifiers = mods stop() return nil } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..7b27845 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -14,6 +14,8 @@ final class Settings { static let historyLimit = "historyLimit" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" + static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" + static let sequenceHotkeyModifiers = "sequenceHotkeyModifiers" static let launchAtLogin = "launchAtLogin" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" @@ -42,6 +44,16 @@ final class Settings { d.set(hotkeyModifiers, forKey: Keys.hotkeyModifiers); HotKeyCenter.shared.reload() } } + var sequenceHotkeyKeyCode: Int { + didSet { guard isLoaded else { return } + d.set(sequenceHotkeyKeyCode, forKey: Keys.sequenceHotkeyKeyCode); HotKeyCenter.shared.reload() } + } + + var sequenceHotkeyModifiers: Int { + didSet { guard isLoaded else { return } + d.set(sequenceHotkeyModifiers, forKey: Keys.sequenceHotkeyModifiers); HotKeyCenter.shared.reload() } + } + var launchAtLogin: Bool { didSet { guard isLoaded else { return } d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } @@ -81,6 +93,8 @@ final class Settings { Keys.historyLimit: 500, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, + Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, + Keys.sequenceHotkeyModifiers: cmdKey | optionKey, Keys.launchAtLogin: false, Keys.pasteDirectly: true, Keys.playSound: false, @@ -92,6 +106,8 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) + sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) + sequenceHotkeyModifiers = d.integer(forKey: Keys.sequenceHotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) @@ -105,4 +121,8 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + var sequenceHotkeyDisplay: String { + HotKeyCenter.describe(keyCode: sequenceHotkeyKeyCode, modifiers: sequenceHotkeyModifiers) + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..fa0921e 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -25,12 +25,25 @@ private struct GeneralSettings: View { var body: some View { Form { Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } + LabeledContent("Show Pesty") { + HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode, + modifiers: $settings.hotkeyModifiers) + } Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { LabeledContent("History limit", value: "\(settings.historyLimit) items") } } + Section("Paste Stack") { + LabeledContent("Paste next clip") { + HotkeyRecorderView(keyCode: $settings.sequenceHotkeyKeyCode, + modifiers: $settings.sequenceHotkeyModifiers) + } + Text("Start a Paste Stack from the strip, then copy clips in another app. Use this shortcut to paste each clip in order.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..aca9d3a 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -85,7 +85,8 @@ final class ClipboardStore { return visibleItems.first(where: { $0.id == id }) } - func addCaptured(_ item: ClipItem) { + @discardableResult + func addCaptured(_ item: ClipItem) -> ClipItem { if let idx = history.firstIndex(where: { $0.sameContent(as: item) }) { if item.imageFileName != history[idx].imageFileName { deleteImageFile(item) } var existing = history.remove(at: idx) @@ -93,7 +94,7 @@ final class ClipboardStore { history.insert(existing, at: 0) if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() - return + return existing } history.insert(item, at: 0) trimHistory() @@ -101,6 +102,7 @@ final class ClipboardStore { selectedID = item.id } scheduleSave() + return item } func applyHistoryLimit() { trimHistory(); scheduleSave() } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift new file mode 100644 index 0000000..b9ab73b --- /dev/null +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -0,0 +1,57 @@ +import AppKit +import Observation + +struct PasteStackEntry: Identifiable { + let id = UUID() + let item: ClipItem + /// Holds an in-memory image while a Stack is active, even if the image is + /// later pruned from clipboard history. + let imagePreview: NSImage? + + init(item: ClipItem, imagePreview: NSImage? = nil) { + self.item = item + self.imagePreview = imagePreview + } +} + +@Observable +@MainActor +final class PasteSequence { + static let shared = PasteSequence() + + private(set) var entries: [PasteStackEntry] = [] + private(set) var isCollecting = false + + var hasEntries: Bool { !entries.isEmpty } + var pendingCount: Int { entries.count } + + private init() {} + + func begin() { + entries.removeAll() + isCollecting = true + } + + @discardableResult + func addIfNeeded(_ item: ClipItem) -> Bool { + guard isCollecting, + !entries.contains(where: { $0.item.id == item.id }) else { return false } + let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil + entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) + return true + } + + func next() -> PasteStackEntry? { + guard !entries.isEmpty else { + isCollecting = false + return nil + } + isCollecting = false + return entries.removeFirst() + } + + func cancel() { + entries.removeAll() + isCollecting = false + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..b7f77f5 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -26,6 +26,7 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + pasteStackButton moreMenu } .padding(.horizontal, 18) @@ -86,6 +87,19 @@ struct BarView: View { .fixedSize() } + private var pasteStackButton: some View { + Button { + AppController.shared.beginPasteSequence() + } label: { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help("Start Paste Stack") + } + private var strip: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift new file mode 100644 index 0000000..4843d83 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct PasteStackView: View { + @Bindable private var stack = PasteSequence.shared + + var body: some View { + VStack(spacing: 0) { + header + Divider() + entries + Divider() + footer + } + .frame(width: 320, height: 380) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.25)) + } + } + + private var header: some View { + HStack(spacing: 9) { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + AppController.shared.cancelPasteSequence() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel Paste Stack") + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(Theme.selection) + Text(stack.isCollecting + ? "Copy text, images, or files in another app to collect them here." + : "Start a new Paste Stack from the strip.") + .font(.system(size: 12, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 210) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 7) { + ForEach(Array(stack.entries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, index: index + 1) + } + } + .padding(12) + } + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button { + AppController.shared.pasteNextInSequence() + } label: { + Label("Paste Next", systemImage: "doc.on.clipboard") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!stack.hasEntries) + + Text(Settings.shared.sequenceHotkeyDisplay) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 15) + .frame(height: 52) + } + + private var summary: String { + if stack.isCollecting { + return stack.hasEntries ? "\(stack.pendingCount) collected - copy more" : "Copy clips in another app" + } + return stack.hasEntries ? "\(stack.pendingCount) ready to paste" : "Stack complete" + } +} + +private struct PasteStackEntryRow: View { + let entry: PasteStackEntry + let index: Int + + var body: some View { + HStack(spacing: 10) { + Text("\(index)") + .font(.caption.weight(.bold)) + .foregroundStyle(entry.item.type.accent) + .frame(width: 18) + Image(systemName: entry.item.type.symbol) + .foregroundStyle(entry.item.type.accent) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Text(entry.item.type.label) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} diff --git a/Sources/Pesty/UI/PasteStackWindowController.swift b/Sources/Pesty/UI/PasteStackWindowController.swift new file mode 100644 index 0000000..055799f --- /dev/null +++ b/Sources/Pesty/UI/PasteStackWindowController.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +final class PasteStackPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +@MainActor +final class PasteStackWindowController: NSWindowController { + init() { + let panel = PasteStackPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 380), + styleMask: [.borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .modalPanel + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: PasteStackView()) + super.init(window: panel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + func show() { + guard let panel = window else { return } + let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return } + + let visible = screen.visibleFrame + let origin = NSPoint(x: visible.maxX - panel.frame.width - 22, + y: visible.maxY - panel.frame.height - 22) + panel.setFrameOrigin(origin) + panel.orderFrontRegardless() + panel.makeKey() + } + + func hide() { + window?.orderOut(nil) + } +} From abd05c77413a246a11ebf129303c7812c6da3c31 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:59:28 -0700 Subject: [PATCH 2/6] Show previews and source apps in Paste Stack --- Sources/Pesty/UI/PasteStackView.swift | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 4843d83..65ab4fa 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -110,9 +110,7 @@ private struct PasteStackEntryRow: View { .font(.caption.weight(.bold)) .foregroundStyle(entry.item.type.accent) .frame(width: 18) - Image(systemName: entry.item.type.symbol) - .foregroundStyle(entry.item.type.accent) - .frame(width: 20) + preview VStack(alignment: .leading, spacing: 2) { Text(entry.item.displayTitle) .font(.system(size: 12, weight: .medium)) @@ -122,9 +120,50 @@ private struct PasteStackEntryRow: View { .foregroundStyle(.secondary) } Spacer(minLength: 0) + sourceAppIcon } .padding(.horizontal, 10) .padding(.vertical, 9) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } + + @ViewBuilder + private var preview: some View { + if let image = previewImage { + Image(nsImage: image) + .resizable() + .interpolation(.medium) + .scaledToFill() + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 38, height: 38) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + } + } + + private var previewImage: NSImage? { + if entry.item.type == .image { return entry.imagePreview } + guard entry.item.type == .file, + entry.item.fileURLs.count == 1, + let urlString = entry.item.fileURLs.first, + let url = URL(string: urlString), + url.isFileURL else { return nil } + return NSImage(contentsOf: url) + } + + private var sourceAppIcon: some View { + Image(nsImage: AppIconProvider.icon(forBundleID: entry.item.sourceBundleID)) + .resizable() + .interpolation(.high) + .frame(width: 19, height: 19) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .help(entry.item.sourceAppName ?? "Source app") + } } From 0c40adce573b9ffb7b4ea84bf3b7b773f832bb25 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:17:04 -0700 Subject: [PATCH 3/6] Add in-bar Paste Stack deck workflow --- Sources/Pesty/AppController.swift | 102 ++++++++++-- Sources/Pesty/Store/ClipboardStore.swift | 6 + Sources/Pesty/Store/PasteSequence.swift | 88 +++++++++- Sources/Pesty/UI/BarView.swift | 22 ++- Sources/Pesty/UI/PasteStackDeckCard.swift | 115 +++++++++++++ Sources/Pesty/UI/PasteStackView.swift | 191 +++++++++++++++++++++- Sources/Pesty/UI/PinboardTabs.swift | 18 ++ 7 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 Sources/Pesty/UI/PasteStackDeckCard.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index a4f2a26..55d87cd 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -133,14 +133,14 @@ final class AppController: NSObject, NSApplicationDelegate { } } - func showBar() { + func showBar(source requestedSource: BarSource? = nil) { let front = NSWorkspace.shared.frontmostApplication if front?.bundleIdentifier != Bundle.main.bundleIdentifier { previousApp = front } store.searchText = "" - store.source = .history - store.selectFirst() + store.source = requestedSource ?? .history + if store.source != .pasteStack { store.selectFirst() } if barController == nil { barController = BarWindowController() @@ -190,32 +190,87 @@ final class AppController: NSObject, NSApplicationDelegate { pasteStackController?.show() } + func hidePasteStack() { + pasteStackController?.hide() + } + + func showPasteStackTab() { + store.searchText = "" + store.source = .pasteStack + pasteSequence.selectFirst() + pasteStackController?.hide() + if barController?.window?.isVisible != true { + showBar(source: .pasteStack) + } + } + func cancelPasteSequence() { pasteSequence.cancel() pasteStackController?.hide() pasteStackTargetApp = nil } + func newPasteStack() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.newStack() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func pausePasteSequence() { + pasteSequence.pause() + } + + func clearPasteStack() { + cancelPasteSequence() + } + func capturePasteStackItem(_ item: ClipItem) { _ = pasteSequence.addIfNeeded(item) } - func pasteNextInSequence() { - guard pasteSequence.hasEntries else { return } + func removePasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.remove(entry) + } + func reAddPasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.reAdd(entry) + } + + func resetPasteStackProgress() { + pasteSequence.resetProgress() + } + + func pasteNextInSequence() { #if !MAS guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } #endif guard let entry = pasteSequence.next() else { return } + performPasteStackEntry(entry) + } + + func pasteStackEntry(_ entry: PasteStackEntry) { + guard let entry = pasteSequence.next(entryID: entry.id) else { return } + performPasteStackEntry(entry) + } + + func pasteSelectedStackEntry() { + guard let entry = pasteSequence.selectedEntry else { return } + pasteStackEntry(entry) + } + + private func performPasteStackEntry(_ entry: PasteStackEntry) { + hideBar() PasteService.paste(entry.item, into: pasteStackTargetApp ?? previousApp, monitor: monitor, imageOverride: entry.imagePreview) - if !pasteSequence.hasEntries { - pasteStackController?.hide() - pasteStackTargetApp = nil - } } func showSettings() { @@ -255,7 +310,11 @@ final class AppController: NSObject, NSApplicationDelegate { let ctrl = flags.contains(.control) let opt = flags.contains(.option) - if cmd, let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n) { + if store.source != .pasteStack, + cmd, + let chars = event.charactersIgnoringModifiers, + let n = Int(chars), + (1...9).contains(n) { let items = store.visibleItems if n <= items.count { pasteItem(items[n - 1]) } return nil @@ -267,25 +326,46 @@ final class AppController: NSObject, NSApplicationDelegate { else { hideBar() } return nil case kVK_Return, kVK_ANSI_KeypadEnter: + if store.source == .pasteStack { + pasteSelectedStackEntry() + return nil + } pasteSelected(); return nil case kVK_LeftArrow, kVK_UpArrow: + if store.source == .pasteStack { + pasteSequence.moveSelection(by: -1) + return nil + } store.moveSelection(by: -1); return nil case kVK_RightArrow, kVK_DownArrow: + if store.source == .pasteStack { + pasteSequence.moveSelection(by: 1) + return nil + } store.moveSelection(by: 1); return nil case kVK_Delete: + if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + return nil + } if cmd, let sel = store.selectedItem { store.delete(sel); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst(); return nil } return nil case kVK_ForwardDelete: + if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + return nil + } if let sel = store.selectedItem { store.delete(sel) } return nil default: break } - if !cmd && !ctrl && !opt, + if store.source != .pasteStack, + !cmd && !ctrl && !opt, let chars = event.characters, chars.count == 1, let scalar = chars.unicodeScalars.first, scalar.value >= 32, scalar.value != 127 { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index aca9d3a..715366e 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -3,6 +3,7 @@ import Observation enum BarSource: Equatable { case history + case pasteStack case pinboard(UUID) } @@ -72,6 +73,11 @@ final class ClipboardStore { switch source { case .history: base = history + case .pasteStack: + // Paste Stack entries have their own identity and selection state. + // PasteStackContentView renders them directly instead of folding + // them into the clipboard history strip. + base = [] case .pinboard(let id): base = pinboards.first(where: { $0.id == id })?.items ?? [] } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index b9ab73b..2fe70af 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -7,6 +7,9 @@ struct PasteStackEntry: Identifiable { /// Holds an in-memory image while a Stack is active, even if the image is /// later pruned from clipboard history. let imagePreview: NSImage? + /// Pasted clips remain in the stack so its deck can show progress and be + /// reset without asking the user to collect the same clips again. + var isPasted = false init(item: ClipItem, imagePreview: NSImage? = nil) { self.item = item @@ -21,15 +24,36 @@ final class PasteSequence { private(set) var entries: [PasteStackEntry] = [] private(set) var isCollecting = false + private(set) var selectedEntryID: UUID? var hasEntries: Bool { !entries.isEmpty } - var pendingCount: Int { entries.count } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + /// Keep pending clips at the front and previously pasted clips at the end + /// of the deck. The next clip is therefore always visually first. + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) + } + var selectedEntry: PasteStackEntry? { + guard let selectedEntryID else { return nil } + return entries.first(where: { $0.id == selectedEntryID }) + } private init() {} func begin() { + isCollecting = true + if selectedEntryID == nil { selectFirst() } + } + + func pause() { + isCollecting = false + } + + func newStack() { entries.removeAll() isCollecting = true + selectedEntryID = nil } @discardableResult @@ -38,20 +62,78 @@ final class PasteSequence { !entries.contains(where: { $0.item.id == item.id }) else { return false } let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) + if selectedEntryID == nil { selectFirst() } return true } + func selectFirst() { + selectedEntryID = displayEntries.first?.id + } + + func select(_ entry: PasteStackEntry) { + selectedEntryID = entry.id + } + + func moveSelection(by delta: Int) { + let displayed = displayEntries + guard !displayed.isEmpty else { + selectedEntryID = nil + return + } + guard let selectedEntryID, + let index = displayed.firstIndex(where: { $0.id == selectedEntryID }) else { + self.selectedEntryID = displayed.first?.id + return + } + let next = max(0, min(displayed.count - 1, index + delta)) + self.selectedEntryID = displayed[next].id + } + func next() -> PasteStackEntry? { - guard !entries.isEmpty else { + guard let index = entries.firstIndex(where: { !$0.isPasted }) else { isCollecting = false return nil } + return takeEntry(at: index) + } + + func next(entryID: UUID) -> PasteStackEntry? { + guard let index = entries.firstIndex(where: { $0.id == entryID && !$0.isPasted }) else { + return nil + } + return takeEntry(at: index) + } + + func reAdd(_ entry: PasteStackEntry) { + entries.append(PasteStackEntry(item: entry.item, imagePreview: entry.imagePreview)) + selectFirst() + } + + func remove(_ entry: PasteStackEntry) { + entries.removeAll { $0.id == entry.id } + if selectedEntryID == entry.id { selectFirst() } + } + + func resetProgress() { + for index in entries.indices { + entries[index].isPasted = false + } isCollecting = false - return entries.removeFirst() + selectFirst() } func cancel() { entries.removeAll() isCollecting = false + selectedEntryID = nil + } + + private func takeEntry(at index: Int) -> PasteStackEntry { + var entry = entries.remove(at: index) + entry.isPasted = true + entries.append(entry) + isCollecting = false + selectFirst() + return entry } } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index b7f77f5..060881f 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,6 +3,11 @@ import SwiftUI struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + private var sequence: PasteSequence { AppController.shared.pasteSequence } + + private var showsStackDeck: Bool { + store.source == .history && store.searchText.isEmpty && sequence.hasEntries + } var body: some View { ZStack { @@ -12,7 +17,11 @@ struct BarView: View { .overlay(alignment: .top) { VStack(spacing: 0) { topBar - strip + if store.source == .pasteStack { + PasteStackContentView() + } else { + strip + } } } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) @@ -22,7 +31,7 @@ struct BarView: View { private var topBar: some View { HStack(spacing: 14) { syncButton - searchIndicator + if store.source != .pasteStack { searchIndicator } PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) @@ -104,6 +113,11 @@ struct BarView: View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: Theme.cardSpacing) { + if showsStackDeck { + PasteStackDeckCard() + .id("pesty.paste-stack.deck") + } + ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in ClipCardView(item: item, index: index, @@ -125,7 +139,9 @@ struct BarView: View { proxy.scrollTo(id, anchor: .center) } } - .overlay { if store.visibleItems.isEmpty { emptyState } } + .overlay { + if store.visibleItems.isEmpty && !showsStackDeck { emptyState } + } } .frame(maxHeight: .infinity) } diff --git a/Sources/Pesty/UI/PasteStackDeckCard.swift b/Sources/Pesty/UI/PasteStackDeckCard.swift new file mode 100644 index 0000000..9398175 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// A compact representation of the active Paste Stack in the Clipboard strip. +/// The card opens the focused stack tab rather than behaving like a history +/// clip, which keeps collection and sequential paste actions unambiguous. +struct PasteStackDeckCard: View { + private var stack: PasteSequence { AppController.shared.pasteSequence } + + private var nextEntry: PasteStackEntry? { + stack.displayEntries.first(where: { !$0.isPasted }) + } + + var body: some View { + Button { AppController.shared.showPasteStackTab() } label: { + ZStack(alignment: .topLeading) { + if stack.pendingCount > 2 { deckLayer(offset: 12, opacity: 0.20) } + if stack.pendingCount > 1 { deckLayer(offset: 6, opacity: 0.34) } + frontCard + } + .frame(width: Theme.cardWidth + 12, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + } + .buttonStyle(.plain) + .help("Open Paste Stack") + } + + private func deckLayer(offset: CGFloat, opacity: Double) -> some View { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .fill(Theme.selection.opacity(opacity)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(Theme.selection.opacity(0.25)) + } + .offset(x: offset, y: offset) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + + private var frontCard: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "rectangle.stack.fill") + .font(.system(size: 16, weight: .semibold)) + VStack(alignment: .leading, spacing: 1) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(stack.isCollecting ? "Collecting clips" : "Ready to paste") + .font(.system(size: 11)) + .foregroundStyle(Theme.headerSubText) + } + Spacer(minLength: 4) + Text("\(stack.pendingCount)") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.18), in: Capsule()) + } + .foregroundStyle(Theme.headerText) + .padding(.horizontal, 13) + .frame(height: Theme.headerHeight) + .background(Theme.selection) + + VStack(spacing: 10) { + if let entry = nextEntry { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 50, height: 50) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + Text(entry.item.displayTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(3) + .multilineTextAlignment(.center) + Text("Next clip") + .font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary) + } else { + Image(systemName: "checkmark.circle") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Stack complete") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.cardBody) + + HStack { + Text("\(stack.pendingCount) queued") + Spacer() + Text("Open stack") + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Theme.cardBody) + } + .frame(width: Theme.cardWidth) + .frame(maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(Theme.selection.opacity(0.7), lineWidth: 2) + } + .shadow(color: .black.opacity(0.16), radius: 5, y: 2) + } +} diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 65ab4fa..2ca0cc7 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -32,13 +32,33 @@ struct PasteStackView: View { } Spacer() Button { - AppController.shared.cancelPasteSequence() + AppController.shared.showPasteStackTab() + } label: { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + } + .buttonStyle(.plain) + .help("Open Paste Stack in Pesty") + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Image(systemName: stack.isCollecting ? "pause.circle.fill" : "play.circle.fill") + .foregroundStyle(stack.isCollecting ? .orange : Theme.selection) + } + .buttonStyle(.plain) + .help(stack.isCollecting ? "Pause collecting clips" : "Resume collecting clips") + Button { + AppController.shared.hidePasteStack() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Cancel Paste Stack") + .help("Hide Paste Stack") } .padding(.horizontal, 15) .padding(.vertical, 12) @@ -63,8 +83,11 @@ struct PasteStackView: View { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 7) { - ForEach(Array(stack.entries.enumerated()), id: \.element.id) { index, entry in - PasteStackEntryRow(entry: entry, index: index + 1) + ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: false) } } .padding(12) @@ -81,12 +104,19 @@ struct PasteStackView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .disabled(!stack.hasEntries) + .disabled(stack.pendingCount == 0) Text(Settings.shared.sequenceHotkeyDisplay) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) Spacer() + if stack.hasEntries { + Button { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Clear Paste Stack") + } } .padding(.horizontal, 15) .frame(height: 52) @@ -96,13 +126,18 @@ struct PasteStackView: View { if stack.isCollecting { return stack.hasEntries ? "\(stack.pendingCount) collected - copy more" : "Copy clips in another app" } - return stack.hasEntries ? "\(stack.pendingCount) ready to paste" : "Stack complete" + if stack.pendingCount > 0 { return "\(stack.pendingCount) ready to paste" } + return stack.hasEntries ? "Stack complete" : "Collection paused" } } private struct PasteStackEntryRow: View { let entry: PasteStackEntry let index: Int + let selected: Bool + let showsPasteAction: Bool + + private var stack: PasteSequence { AppController.shared.pasteSequence } var body: some View { HStack(spacing: 10) { @@ -120,11 +155,41 @@ private struct PasteStackEntryRow: View { .foregroundStyle(.secondary) } Spacer(minLength: 0) - sourceAppIcon + VStack(alignment: .trailing, spacing: 6) { + if showsPasteAction { + if entry.isPasted { + Button("Re-add") { AppController.shared.reAddPasteStackEntry(entry) } + .buttonStyle(.bordered) + .controlSize(.mini) + } else { + Button("Paste") { AppController.shared.pasteStackEntry(entry) } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + } + } + HStack(spacing: 7) { + sourceAppIcon + Button { AppController.shared.removePasteStackEntry(entry) } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Remove from Paste Stack") + } + } } .padding(.horizontal, 10) .padding(.vertical, 9) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(rowBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(selected ? Theme.selection : .clear, lineWidth: selected ? 2 : 0) + } + .opacity(entry.isPasted ? 0.52 : 1) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .onTapGesture { stack.select(entry) } + .help(entry.isPasted ? "Pasted clip" : "Select this stack clip") } @ViewBuilder @@ -166,4 +231,114 @@ private struct PasteStackEntryRow: View { .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .help(entry.item.sourceAppName ?? "Source app") } + + private var rowBackground: Color { + selected ? Theme.selection.opacity(0.13) : Color.white.opacity(0.10) + } +} + +/// The full Paste Stack tab in Pesty. It exposes the active deck without +/// turning its entries into ordinary clipboard-history cards. +struct PasteStackContentView: View { + private var stack: PasteSequence { AppController.shared.pasteSequence } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + entries + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 9) { + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 16, weight: .bold)) + Text(summary) + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + Spacer() + + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Label(stack.isCollecting ? "Pause" : "Collect", + systemImage: stack.isCollecting ? "pause.fill" : "play.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(stack.isCollecting ? .orange : Theme.selection) + + if stack.pendingCount > 0 { + Button("Paste Next") { AppController.shared.pasteNextInSequence() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + if stack.pastedCount > 0 { + Button("Reset") { AppController.shared.resetPasteStackProgress() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Make every Paste Stack clip ready again") + } + + Button("New Stack") { AppController.shared.newPasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + + if stack.hasEntries { + Button(role: .destructive) { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Clear Paste Stack") + } + } + .padding(.horizontal, 24) + .padding(.vertical, 13) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Start collecting clips") + .font(.system(size: 15, weight: .semibold)) + Text("Choose Collect, then copy text, images, or files in any app.") + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 8) { + ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: true) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } + } + + private var summary: String { + if stack.isCollecting { return "Collecting clips from other apps" } + if stack.pendingCount > 0 { return "\(stack.pendingCount) clips ready to paste" } + return stack.hasEntries ? "All clips pasted" : "Collection paused" + } } diff --git a/Sources/Pesty/UI/PinboardTabs.swift b/Sources/Pesty/UI/PinboardTabs.swift index 01aa291..379e9da 100644 --- a/Sources/Pesty/UI/PinboardTabs.swift +++ b/Sources/Pesty/UI/PinboardTabs.swift @@ -2,6 +2,7 @@ import SwiftUI struct PinboardTabs: View { @Bindable private var store = ClipboardStore.shared + private var stack: PasteSequence { AppController.shared.pasteSequence } var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -13,6 +14,14 @@ struct PinboardTabs: View { store.source = .history; store.selectFirst() } + pill(title: "Paste Stack", + dot: nil, + icon: "rectangle.stack.fill", + badge: stack.pendingCount, + selected: store.source == .pasteStack) { + AppController.shared.showPasteStackTab() + } + ForEach(store.pinboards) { board in pill(title: board.name, dot: board.color, @@ -41,6 +50,7 @@ struct PinboardTabs: View { } private func pill(title: String, dot: Color?, icon: String? = nil, + badge: Int? = nil, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { @@ -54,6 +64,14 @@ struct PinboardTabs: View { Text(title) .font(.system(size: 12.5, weight: .medium)) .lineLimit(1) + if let badge, badge > 0 { + Text("\(badge)") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(selected ? .white : Theme.selection) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(selected ? Theme.selection : Theme.selection.opacity(0.14), in: Capsule()) + } } .foregroundStyle(selected ? Theme.textPrimary : Theme.textSecondary) .padding(.horizontal, 12) From e467cd3f01d949c54ec5b4f622555dbf514e62f7 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:20:34 -0700 Subject: [PATCH 4/6] Paste stacks into active apps and save them --- Sources/Pesty/AppController.swift | 33 ++++++++++++++++++++++++++- Sources/Pesty/UI/PasteStackView.swift | 11 +++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 55d87cd..e0c551b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -246,6 +246,20 @@ final class AppController: NSObject, NSApplicationDelegate { pasteSequence.resetProgress() } + /// Saves the deck in its displayed paste order so a temporary Paste Stack + /// can become a durable Pinboard. + func savePasteStack() { + guard pasteSequence.hasEntries, + let name = TextPrompt.run(title: "Save Paste Stack", + message: "Save the current stack as a pinboard named:", + defaultValue: "Paste Stack") else { return } + + let board = store.addPinboard(name: name) + for entry in pasteSequence.displayEntries.reversed() { + store.saveToPinboard(entry.item, boardID: board.id) + } + } + func pasteNextInSequence() { #if !MAS guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } @@ -266,13 +280,30 @@ final class AppController: NSObject, NSApplicationDelegate { } private func performPasteStackEntry(_ entry: PasteStackEntry) { + let target = pasteTargetApp() hideBar() PasteService.paste(entry.item, - into: pasteStackTargetApp ?? previousApp, + into: target, monitor: monitor, imageOverride: entry.imagePreview) } + /// Resolve the destination when the user chooses to paste, rather than + /// holding the app that was active when the Stack was first opened. + private func pasteTargetApp() -> NSRunningApplication? { + if let frontmost = NSWorkspace.shared.frontmostApplication, + frontmost.bundleIdentifier != Bundle.main.bundleIdentifier { + previousApp = frontmost + return frontmost + } + if let lastActiveApp, + lastActiveApp.bundleIdentifier != Bundle.main.bundleIdentifier, + !lastActiveApp.isTerminated { + return lastActiveApp + } + return pasteStackTargetApp ?? previousApp + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 2ca0cc7..464becb 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -111,6 +111,10 @@ struct PasteStackView: View { .foregroundStyle(.secondary) Spacer() if stack.hasEntries { + Button("Save") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") Button { AppController.shared.clearPasteStack() } label: { Image(systemName: "trash") } @@ -289,6 +293,13 @@ struct PasteStackContentView: View { .help("Make every Paste Stack clip ready again") } + if stack.hasEntries { + Button("Save Stack…") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") + } + Button("New Stack") { AppController.shared.newPasteStack() } .buttonStyle(.bordered) .controlSize(.small) From 575cada8b74440722790381f509a4ad67d2e84a9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:53:52 -0700 Subject: [PATCH 5/6] Add count and time history retention --- Sources/Pesty/AppController.swift | 1 + Sources/Pesty/Settings/Settings.swift | 132 +++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 50 +++++++- Sources/Pesty/Store/ClipboardStore.swift | 33 ++++-- 4 files changed, 201 insertions(+), 15 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index e0c551b..f481daf 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -140,6 +140,7 @@ final class AppController: NSObject, NSApplicationDelegate { } store.searchText = "" store.source = requestedSource ?? .history + store.applyHistoryPolicy() if store.source != .pasteStack { store.selectFirst() } if barController == nil { diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 7b27845..c572989 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,112 @@ import AppKit import Carbon.HIToolbox import Observation +enum HistoryRetention: Int, CaseIterable, Identifiable { + case day + case week + case month + case year + case forever + case twoWeeks + case threeWeeks + case twoMonths + case threeMonths + case sixMonths + + // Preserve the original raw values so a saved selection remains valid as + // additional intervals are introduced. + static let allCases: [HistoryRetention] = [ + .day, .week, .twoWeeks, .threeWeeks, .month, + .twoMonths, .threeMonths, .sixMonths, .year, .forever + ] + + var id: Int { rawValue } + + var title: String { + switch self { + case .day: "1 Day" + case .week: "1 Week" + case .twoWeeks: "2 Weeks" + case .threeWeeks: "3 Weeks" + case .month: "1 Month" + case .twoMonths: "2 Months" + case .threeMonths: "3 Months" + case .sixMonths: "6 Months" + case .year: "1 Year" + case .forever: "Forever" + } + } + + var description: String { + switch self { + case .day: "Clips are kept for 24 hours." + case .week: "Clips are kept for 7 days." + case .twoWeeks: "Clips are kept for 2 weeks." + case .threeWeeks: "Clips are kept for 3 weeks." + case .month: "Clips are kept for 1 month." + case .twoMonths: "Clips are kept for 2 months." + case .threeMonths: "Clips are kept for 3 months." + case .sixMonths: "Clips are kept for 6 months." + case .year: "Clips are kept for 1 year." + case .forever: "Clips are kept until you erase them." + } + } + + var cutoffDate: Date? { + let calendar = Calendar.current + switch self { + case .day: return calendar.date(byAdding: .day, value: -1, to: .now) + case .week: return calendar.date(byAdding: .day, value: -7, to: .now) + case .twoWeeks: return calendar.date(byAdding: .day, value: -14, to: .now) + case .threeWeeks: return calendar.date(byAdding: .day, value: -21, to: .now) + case .month: return calendar.date(byAdding: .month, value: -1, to: .now) + case .twoMonths: return calendar.date(byAdding: .month, value: -2, to: .now) + case .threeMonths: return calendar.date(byAdding: .month, value: -3, to: .now) + case .sixMonths: return calendar.date(byAdding: .month, value: -6, to: .now) + case .year: return calendar.date(byAdding: .year, value: -1, to: .now) + case .forever: return nil + } + } + + var sliderIndex: Double { + Double(Self.allCases.firstIndex(of: self) ?? 0) + } + + var shortSliderTitle: String { + switch self { + case .day: "1d" + case .week: "1w" + case .twoWeeks: "2w" + case .threeWeeks: "3w" + case .month: "1m" + case .twoMonths: "2m" + case .threeMonths: "3m" + case .sixMonths: "6m" + case .year: "1y" + case .forever: "∞" + } + } + + init(sliderIndex: Double) { + let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded()))) + self = Self.allCases[index] + } +} + +enum HistoryRetentionMode: Int, CaseIterable, Identifiable { + case itemCount + case timePeriod + + var id: Int { rawValue } + + var title: String { + switch self { + case .itemCount: "Number" + case .timePeriod: "Time" + } + } +} + @Observable @MainActor final class Settings { @@ -12,6 +118,8 @@ final class Settings { enum Keys { static let historyLimit = "historyLimit" + static let historyRetentionMode = "historyRetentionMode" + static let historyRetention = "historyRetention" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" @@ -30,7 +138,25 @@ final class Settings { guard isLoaded else { return } if historyLimit < 20 { historyLimit = 20; return } d.set(historyLimit, forKey: Keys.historyLimit) - ClipboardStore.shared.applyHistoryLimit() + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetentionMode: HistoryRetentionMode { + didSet { + guard isLoaded else { return } + d.set(historyRetentionMode.rawValue, forKey: Keys.historyRetentionMode) + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetention: HistoryRetention { + didSet { + guard isLoaded else { return } + d.set(historyRetention.rawValue, forKey: Keys.historyRetention) + if historyRetentionMode == .timePeriod { + ClipboardStore.shared.applyHistoryPolicy() + } } } @@ -91,6 +217,8 @@ final class Settings { private init() { d.register(defaults: [ Keys.historyLimit: 500, + Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, + Keys.historyRetention: HistoryRetention.month.rawValue, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, @@ -104,6 +232,8 @@ final class Settings { Keys.iCloudSync: false ]) historyLimit = d.integer(forKey: Keys.historyLimit) + historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount + historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index fa0921e..d28fca9 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -29,8 +29,47 @@ private struct GeneralSettings: View { HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode, modifiers: $settings.hotkeyModifiers) } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + } + + Section("History") { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + Spacer() + Text(settings.historyRetention.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: retentionSliderValue, + in: 0...Double(HistoryRetention.allCases.count - 1), + step: 1) + HStack(spacing: 0) { + ForEach(HistoryRetention.allCases) { retention in + Text(retention.shortSliderTitle) + .font(.caption2.weight(retention == settings.historyRetention ? .bold : .medium)) + .foregroundStyle(retention == settings.historyRetention ? Color.accentColor : .secondary) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) } } @@ -124,6 +163,13 @@ private struct GeneralSettings: View { } } #endif + + private var retentionSliderValue: Binding { + Binding( + get: { settings.historyRetention.sliderIndex }, + set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } + ) + } } private struct AboutView: View { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 715366e..a40cdf6 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -19,11 +19,6 @@ final class ClipboardStore { var searchText: String = "" var selectedID: UUID? - var historyLimit: Int { - get { Settings.shared.historyLimit } - set { Settings.shared.historyLimit = newValue; trimHistory() } - } - private var storeURL: URL private var imagesDir: URL private var baseDir: URL @@ -58,6 +53,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -98,12 +94,13 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() return existing } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = item.id } @@ -111,13 +108,24 @@ final class ClipboardStore { return item } - func applyHistoryLimit() { trimHistory(); scheduleSave() } + func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } - private func trimHistory() { - guard history.count > historyLimit else { return } - let removed = Array(history[historyLimit...]) - history.removeLast(history.count - historyLimit) + @discardableResult + private func applyHistoryPolicyNow() -> Bool { + let removed: [ClipItem] + switch Settings.shared.historyRetentionMode { + case .itemCount: + guard history.count > Settings.shared.historyLimit else { return false } + removed = Array(history[Settings.shared.historyLimit...]) + history.removeLast(history.count - Settings.shared.historyLimit) + case .timePeriod: + guard let cutoff = Settings.shared.historyRetention.cutoffDate else { return false } + removed = history.filter { $0.createdAt < cutoff } + guard !removed.isEmpty else { return false } + history.removeAll { $0.createdAt < cutoff } + } for item in removed { deleteImageFile(item) } + return true } func delete(_ item: ClipItem) { @@ -306,7 +314,8 @@ final class ClipboardStore { var seen = Set() var merged: [ClipItem] = [] for it in combined where seen.insert(contentKey(it)).inserted { merged.append(it) } - history = Array(merged.prefix(historyLimit)) + history = merged + applyHistoryPolicyNow() var byID: [UUID: Pinboard] = Dictionary(uniqueKeysWithValues: pinboards.map { ($0.id, $0) }) for b in snap.pinboards { From 62192090fdd4b9b70d66260f666934b5976a076b Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:29:10 -0700 Subject: [PATCH 6/6] Persist saved Paste Stacks --- Sources/Pesty/AppController.swift | 8 +- Sources/Pesty/Settings/Settings.swift | 9 ++ Sources/Pesty/Settings/SettingsView.swift | 4 + Sources/Pesty/Store/ClipboardStore.swift | 44 ++++- Sources/Pesty/Store/PasteSequence.swift | 188 ++++++++++++++++++++-- Sources/Pesty/UI/BarView.swift | 12 +- Sources/Pesty/UI/PasteStackDeckCard.swift | 22 ++- Sources/Pesty/UI/PasteStackView.swift | 29 +++- 8 files changed, 286 insertions(+), 30 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index f481daf..f2cf6fe 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -195,10 +195,14 @@ final class AppController: NSObject, NSApplicationDelegate { pasteStackController?.hide() } - func showPasteStackTab() { + func showPasteStackTab(stackID: UUID? = nil) { store.searchText = "" store.source = .pasteStack - pasteSequence.selectFirst() + if let stackID { + pasteSequence.selectStack(stackID) + } else { + pasteSequence.selectFirst() + } pasteStackController?.hide() if barController?.window?.isVisible != true { showBar(source: .pasteStack) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c572989..77ab257 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -120,6 +120,7 @@ final class Settings { static let historyLimit = "historyLimit" static let historyRetentionMode = "historyRetentionMode" static let historyRetention = "historyRetention" + static let pasteStacksFollowHistory = "pasteStacksFollowHistory" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" @@ -160,6 +161,12 @@ final class Settings { } } + /// When enabled, removing clipboard history also removes those clips from + /// saved Paste Stacks. The default keeps stacks until the user deletes them. + var pasteStacksFollowHistory: Bool { + didSet { guard isLoaded else { return }; d.set(pasteStacksFollowHistory, forKey: Keys.pasteStacksFollowHistory) } + } + var hotkeyKeyCode: Int { didSet { guard isLoaded else { return } d.set(hotkeyKeyCode, forKey: Keys.hotkeyKeyCode); HotKeyCenter.shared.reload() } @@ -219,6 +226,7 @@ final class Settings { Keys.historyLimit: 500, Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, Keys.historyRetention: HistoryRetention.month.rawValue, + Keys.pasteStacksFollowHistory: false, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, @@ -234,6 +242,7 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month + pasteStacksFollowHistory = d.bool(forKey: Keys.pasteStacksFollowHistory) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index d28fca9..e75cbf3 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -81,6 +81,10 @@ private struct GeneralSettings: View { Text("Start a Paste Stack from the strip, then copy clips in another app. Use this shortcut to paste each clip in order.") .font(.caption) .foregroundStyle(.secondary) + Toggle("Remove saved stacks with clipboard history", isOn: $settings.pasteStacksFollowHistory) + Text("When enabled, deleting or retaining clipboard history also removes the matching saved stack clips.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Behavior") { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index a40cdf6..12ef1d3 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -78,7 +78,13 @@ final class ClipboardStore { base = pinboards.first(where: { $0.id == id })?.items ?? [] } let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return base } + guard !q.isEmpty else { + // Each saved Paste Stack is represented by one deck card on + // Clipboard. Its clips remain in history for persistence, but do + // not also appear as individual Clipboard cards. + guard case .history = source, PasteSequence.shared.hasSavedStacks else { return base } + return base.filter { !PasteSequence.shared.containsHistoryItemID($0.id) } + } return base.filter { $0.searchableText.contains(q) } } @@ -110,6 +116,10 @@ final class ClipboardStore { func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } + func pasteStacksDidChange() { + scheduleSave() + } + @discardableResult private func applyHistoryPolicyNow() -> Bool { let removed: [ClipItem] @@ -124,6 +134,9 @@ final class ClipboardStore { guard !removed.isEmpty else { return false } history.removeAll { $0.createdAt < cutoff } } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(removed.map(\.id))) + } for item in removed { deleteImageFile(item) } return true } @@ -131,6 +144,9 @@ final class ClipboardStore { func delete(_ item: ClipItem) { history.removeAll { $0.id == item.id } for i in pinboards.indices { pinboards[i].items.removeAll { $0.id == item.id } } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems([item.id]) + } deleteImageFile(item) if selectedID == item.id { selectFirst() } scheduleSave() @@ -140,6 +156,9 @@ final class ClipboardStore { let old = history history.removeAll() selectedID = nil + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(old.map(\.id))) + } for item in old { deleteImageFile(item) } scheduleSave() } @@ -234,6 +253,9 @@ final class ClipboardStore { guard let name = item.imageFileName else { return } let stillUsed = history.contains { $0.imageFileName == name } || pinboards.contains { $0.items.contains { $0.imageFileName == name } } + || PasteSequence.shared.savedStacks.contains { stack in + stack.entries.contains { $0.item.imageFileName == name } + } if stillUsed { return } if let url = imageURL(for: item) { try? FileManager.default.removeItem(at: url) } } @@ -241,6 +263,7 @@ final class ClipboardStore { private struct Snapshot: Codable { var history: [ClipItem] var pinboards: [Pinboard] + var pasteStacks: [SavedPasteStack]? } private func load() { @@ -248,6 +271,7 @@ final class ClipboardStore { let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } history = snap.history pinboards = snap.pinboards + PasteSequence.shared.restoreSavedStacks(snap.pasteStacks ?? []) selectFirst() } @@ -259,7 +283,9 @@ final class ClipboardStore { } func saveNow() { - let snap = Snapshot(history: history, pinboards: pinboards) + let snap = Snapshot(history: history, + pinboards: pinboards, + pasteStacks: PasteSequence.shared.savedStacks) guard let data = try? JSONEncoder().encode(snap) else { return } ignoreWatchUntil = Date().addingTimeInterval(1.5) try? data.write(to: storeURL, options: .atomic) @@ -331,6 +357,20 @@ final class ClipboardStore { pinboards = pinboards.map { byID[$0.id] ?? $0 } + byID.values.filter { b in !pinboards.contains(where: { $0.id == b.id }) } + var stacksByID: [UUID: SavedPasteStack] = Dictionary( + uniqueKeysWithValues: PasteSequence.shared.savedStacks.map { ($0.id, $0) } + ) + for stack in snap.pasteStacks ?? [] { + if let local = stacksByID[stack.id] { + stacksByID[stack.id] = local.updatedAt >= stack.updatedAt ? local : stack + } else { + stacksByID[stack.id] = stack + } + } + PasteSequence.shared.restoreSavedStacks( + stacksByID.values.sorted { $0.createdAt > $1.createdAt } + ) + combined.removeAll() selectFirst() if history.count != before || !snap.history.isEmpty { saveNow() } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index 2fe70af..3033f45 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -1,19 +1,62 @@ import AppKit import Observation -struct PasteStackEntry: Identifiable { - let id = UUID() +struct PasteStackEntry: Identifiable, Codable { + let id: UUID let item: ClipItem - /// Holds an in-memory image while a Stack is active, even if the image is - /// later pruned from clipboard history. + /// This stays in memory only. The persisted clip image remains in + /// ClipboardStore and is loaded again after relaunch. let imagePreview: NSImage? /// Pasted clips remain in the stack so its deck can show progress and be /// reset without asking the user to collect the same clips again. - var isPasted = false + var isPasted: Bool - init(item: ClipItem, imagePreview: NSImage? = nil) { + init(id: UUID = UUID(), item: ClipItem, imagePreview: NSImage? = nil, isPasted: Bool = false) { + self.id = id self.item = item self.imagePreview = imagePreview + self.isPasted = isPasted + } + + private enum CodingKeys: String, CodingKey { case id, item, isPasted } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + item = try container.decode(ClipItem.self, forKey: .item) + isPasted = try container.decode(Bool.self, forKey: .isPasted) + imagePreview = nil + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(item, forKey: .item) + try container.encode(isPasted, forKey: .isPasted) + } +} + +/// One persisted Paste Stack deck. Its entries remain ordered and pasteable, +/// but are represented by a single card in clipboard history. +struct SavedPasteStack: Identifiable, Codable { + let id: UUID + let createdAt: Date + var updatedAt: Date + var entries: [PasteStackEntry] + + init(id: UUID = UUID(), createdAt: Date = .now, entries: [PasteStackEntry] = []) { + self.id = id + self.createdAt = createdAt + self.updatedAt = createdAt + self.entries = entries + } + + var hasEntries: Bool { !entries.isEmpty } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) } } @@ -22,11 +65,16 @@ struct PasteStackEntry: Identifiable { final class PasteSequence { static let shared = PasteSequence() + /// The active stack is mirrored here for the existing collection and paste + /// UI. Every mutation is written back to its SavedPasteStack. private(set) var entries: [PasteStackEntry] = [] private(set) var isCollecting = false private(set) var selectedEntryID: UUID? + private(set) var savedStacks: [SavedPasteStack] = [] + private(set) var activeStackID: UUID? var hasEntries: Bool { !entries.isEmpty } + var hasSavedStacks: Bool { savedStacks.contains(where: \.hasEntries) } var pendingCount: Int { entries.count(where: { !$0.isPasted }) } var pastedCount: Int { entries.count - pendingCount } /// Keep pending clips at the front and previously pasted clips at the end @@ -39,30 +87,62 @@ final class PasteSequence { return entries.first(where: { $0.id == selectedEntryID }) } + func containsHistoryItemID(_ id: UUID) -> Bool { + savedStacks.contains { stack in stack.entries.contains { $0.item.id == id } } + } + private init() {} + func restoreSavedStacks(_ stacks: [SavedPasteStack]) { + savedStacks = stacks.sorted { $0.createdAt > $1.createdAt } + guard let newest = savedStacks.first(where: \.hasEntries) else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + return + } + activate(newest) + } + + func selectStack(_ id: UUID) { + guard let stack = savedStacks.first(where: { $0.id == id }) else { return } + activate(stack) + } + + /// Opens the selected stack for collection without discarding saved decks. func begin() { + ensureActiveStack() isCollecting = true if selectedEntryID == nil { selectFirst() } + persistActiveStack() } func pause() { isCollecting = false + persistActiveStack() } + /// Starts a distinct, empty saved stack. Previous stacks remain as decks. func newStack() { - entries.removeAll() - isCollecting = true + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] selectedEntryID = nil + isCollecting = true + persistActiveStack() } @discardableResult func addIfNeeded(_ item: ClipItem) -> Bool { + ensureActiveStack() guard isCollecting, !entries.contains(where: { $0.item.id == item.id }) else { return false } let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) if selectedEntryID == nil { selectFirst() } + persistActiveStack() return true } @@ -92,6 +172,7 @@ final class PasteSequence { func next() -> PasteStackEntry? { guard let index = entries.firstIndex(where: { !$0.isPasted }) else { isCollecting = false + persistActiveStack() return nil } return takeEntry(at: index) @@ -105,27 +186,76 @@ final class PasteSequence { } func reAdd(_ entry: PasteStackEntry) { + ensureActiveStack() entries.append(PasteStackEntry(item: entry.item, imagePreview: entry.imagePreview)) selectFirst() + persistActiveStack() } func remove(_ entry: PasteStackEntry) { entries.removeAll { $0.id == entry.id } if selectedEntryID == entry.id { selectFirst() } + persistActiveStack() } func resetProgress() { - for index in entries.indices { - entries[index].isPasted = false - } + for index in entries.indices { entries[index].isPasted = false } isCollecting = false selectFirst() + persistActiveStack() } - func cancel() { - entries.removeAll() + func finishCollecting() { isCollecting = false - selectedEntryID = nil + persistActiveStack() + } + + /// Deletes only the active saved stack; all other deck cards remain. + func cancel() { + guard let activeStackID else { return } + savedStacks.removeAll { $0.id == activeStackID } + if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + self.activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() + } + + func deleteStack(_ id: UUID) { + guard savedStacks.contains(where: { $0.id == id }) else { return } + if activeStackID == id { + cancel() + } else { + savedStacks.removeAll { $0.id == id } + ClipboardStore.shared.pasteStacksDidChange() + } + } + + /// Used when the user elects to have saved stacks follow clipboard + /// retention. Empty stacks are removed with their final history item. + func removeHistoryItems(_ ids: Set) { + guard !ids.isEmpty else { return } + for index in savedStacks.indices { + savedStacks[index].entries.removeAll { ids.contains($0.item.id) } + } + savedStacks.removeAll { !$0.hasEntries } + if let activeStackID, + let active = savedStacks.first(where: { $0.id == activeStackID }) { + entries = active.entries + selectFirst() + } else if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() } private func takeEntry(at index: Int) -> PasteStackEntry { @@ -134,6 +264,36 @@ final class PasteSequence { entries.append(entry) isCollecting = false selectFirst() + persistActiveStack() return entry } + + private func ensureActiveStack() { + if let activeStackID, + savedStacks.contains(where: { $0.id == activeStackID }) { return } + if let saved = savedStacks.first(where: \.hasEntries) { + activate(saved) + return + } + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] + selectedEntryID = nil + } + + private func activate(_ stack: SavedPasteStack) { + activeStackID = stack.id + entries = stack.entries + isCollecting = false + selectFirst() + } + + private func persistActiveStack() { + guard let activeStackID, + let index = savedStacks.firstIndex(where: { $0.id == activeStackID }) else { return } + savedStacks[index].entries = entries + savedStacks[index].updatedAt = .now + ClipboardStore.shared.pasteStacksDidChange() + } } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 060881f..dec21ab 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -6,7 +6,7 @@ struct BarView: View { private var sequence: PasteSequence { AppController.shared.pasteSequence } private var showsStackDeck: Bool { - store.source == .history && store.searchText.isEmpty && sequence.hasEntries + store.source == .history && store.searchText.isEmpty && sequence.hasSavedStacks } var body: some View { @@ -98,7 +98,7 @@ struct BarView: View { private var pasteStackButton: some View { Button { - AppController.shared.beginPasteSequence() + AppController.shared.newPasteStack() } label: { Image(systemName: "rectangle.stack.badge.plus") .font(.system(size: 15, weight: .medium)) @@ -114,8 +114,12 @@ struct BarView: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: Theme.cardSpacing) { if showsStackDeck { - PasteStackDeckCard() - .id("pesty.paste-stack.deck") + ForEach(sequence.savedStacks.filter(\.hasEntries)) { stack in + PasteStackDeckCard(stack: stack, + isActive: stack.id == sequence.activeStackID, + isCollecting: stack.id == sequence.activeStackID && sequence.isCollecting) + .id(stack.id) + } } ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in diff --git a/Sources/Pesty/UI/PasteStackDeckCard.swift b/Sources/Pesty/UI/PasteStackDeckCard.swift index 9398175..a82ada0 100644 --- a/Sources/Pesty/UI/PasteStackDeckCard.swift +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -1,17 +1,18 @@ import SwiftUI -/// A compact representation of the active Paste Stack in the Clipboard strip. -/// The card opens the focused stack tab rather than behaving like a history -/// clip, which keeps collection and sequential paste actions unambiguous. +/// A compact representation of one saved Paste Stack in the Clipboard strip. +/// It opens that stack rather than exposing its clips as history cards. struct PasteStackDeckCard: View { - private var stack: PasteSequence { AppController.shared.pasteSequence } + let stack: SavedPasteStack + let isActive: Bool + let isCollecting: Bool private var nextEntry: PasteStackEntry? { stack.displayEntries.first(where: { !$0.isPasted }) } var body: some View { - Button { AppController.shared.showPasteStackTab() } label: { + Button { AppController.shared.showPasteStackTab(stackID: stack.id) } label: { ZStack(alignment: .topLeading) { if stack.pendingCount > 2 { deckLayer(offset: 12, opacity: 0.20) } if stack.pendingCount > 1 { deckLayer(offset: 6, opacity: 0.34) } @@ -44,7 +45,7 @@ struct PasteStackDeckCard: View { VStack(alignment: .leading, spacing: 1) { Text("Paste Stack") .font(.system(size: 15, weight: .bold)) - Text(stack.isCollecting ? "Collecting clips" : "Ready to paste") + Text(statusText) .font(.system(size: 11)) .foregroundStyle(Theme.headerSubText) } @@ -108,8 +109,15 @@ struct PasteStackDeckCard: View { .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) - .strokeBorder(Theme.selection.opacity(0.7), lineWidth: 2) + .strokeBorder(isActive ? Theme.selection.opacity(0.9) : Theme.selection.opacity(0.45), + lineWidth: isActive ? 2 : 1) } .shadow(color: .black.opacity(0.16), radius: 5, y: 2) } + + private var statusText: String { + if isActive && isCollecting { return "Collecting clips" } + if stack.pendingCount > 0 { return "Ready to paste" } + return "Completed stack" + } } diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 464becb..64e9fa2 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -218,7 +218,9 @@ private struct PasteStackEntryRow: View { } private var previewImage: NSImage? { - if entry.item.type == .image { return entry.imagePreview } + if entry.item.type == .image { + return entry.imagePreview ?? ClipboardStore.shared.loadImage(for: entry.item) + } guard entry.item.type == .file, entry.item.fileURLs.count == 1, let urlString = entry.item.fileURLs.first, @@ -264,6 +266,22 @@ struct PasteStackContentView: View { .font(.system(size: 12)) .foregroundStyle(Theme.textSecondary) } + if savedStacks.count > 1 { + Menu { + ForEach(savedStacks) { saved in + Button(stackLabel(for: saved)) { + stack.selectStack(saved.id) + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Choose a saved Paste Stack") + } Spacer() Button { @@ -352,4 +370,13 @@ struct PasteStackContentView: View { if stack.pendingCount > 0 { return "\(stack.pendingCount) clips ready to paste" } return stack.hasEntries ? "All clips pasted" : "Collection paused" } + + private var savedStacks: [SavedPasteStack] { + stack.savedStacks.filter(\.hasEntries) + } + + private func stackLabel(for saved: SavedPasteStack) -> String { + let state = saved.pendingCount > 0 ? "\(saved.pendingCount) ready" : "completed" + return "\(saved.createdAt.clipRelativeLong) · \(state)" + } }