diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..f2cf6fe 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() @@ -129,14 +133,15 @@ 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 + store.applyHistoryPolicy() + if store.source != .pasteStack { store.selectFirst() } if barController == nil { barController = BarWindowController() @@ -167,6 +172,143 @@ 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 hidePasteStack() { + pasteStackController?.hide() + } + + func showPasteStackTab(stackID: UUID? = nil) { + store.searchText = "" + store.source = .pasteStack + if let stackID { + pasteSequence.selectStack(stackID) + } else { + 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 removePasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.remove(entry) + } + + func reAddPasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.reAdd(entry) + } + + func resetPasteStackProgress() { + 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 } + #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) { + let target = pasteTargetApp() + hideBar() + PasteService.paste(entry.item, + 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 { @@ -204,7 +346,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 @@ -216,25 +362,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/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..77ab257 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,8 +118,13 @@ final class Settings { enum Keys { 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" + static let sequenceHotkeyModifiers = "sequenceHotkeyModifiers" static let launchAtLogin = "launchAtLogin" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" @@ -28,10 +139,34 @@ 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() + } + } + } + + /// 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() } @@ -42,6 +177,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) } @@ -79,8 +224,13 @@ final class Settings { private init() { d.register(defaults: [ 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, + Keys.sequenceHotkeyModifiers: cmdKey | optionKey, Keys.launchAtLogin: false, Keys.pasteDirectly: true, Keys.playSound: false, @@ -90,8 +240,13 @@ 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 + pasteStacksFollowHistory = d.bool(forKey: Keys.pasteStacksFollowHistory) 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 +260,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..e75cbf3 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -25,12 +25,68 @@ private struct GeneralSettings: View { var body: some View { Form { Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + LabeledContent("Show Pesty") { + HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode, + modifiers: $settings.hotkeyModifiers) } } + 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) + } + } + + 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) + 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") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) @@ -111,6 +167,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 5db8257..12ef1d3 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) } @@ -18,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 @@ -57,6 +53,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -72,11 +69,22 @@ 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 ?? [] } 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) } } @@ -85,36 +93,60 @@ 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) existing.createdAt = item.createdAt history.insert(existing, at: 0) + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() - return + return existing } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = item.id } scheduleSave() + 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) + func pasteStacksDidChange() { + scheduleSave() + } + + @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 } + } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(removed.map(\.id))) + } for item in removed { deleteImageFile(item) } + return true } 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() @@ -124,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() } @@ -218,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) } } @@ -225,6 +263,7 @@ final class ClipboardStore { private struct Snapshot: Codable { var history: [ClipItem] var pinboards: [Pinboard] + var pasteStacks: [SavedPasteStack]? } private func load() { @@ -232,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() } @@ -243,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) @@ -298,7 +340,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 { @@ -314,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 new file mode 100644 index 0000000..3033f45 --- /dev/null +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -0,0 +1,299 @@ +import AppKit +import Observation + +struct PasteStackEntry: Identifiable, Codable { + let id: UUID + let item: ClipItem + /// 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: Bool + + 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) + } +} + +@Observable +@MainActor +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 + /// 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 }) + } + + 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() { + 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 + } + + 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 let index = entries.firstIndex(where: { !$0.isPasted }) else { + isCollecting = false + persistActiveStack() + 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) { + 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 } + isCollecting = false + selectFirst() + persistActiveStack() + } + + func finishCollecting() { + isCollecting = false + 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 { + var entry = entries.remove(at: index) + entry.isPasted = true + 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 c37985b..dec21ab 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.hasSavedStacks + } 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,10 +31,11 @@ struct BarView: View { private var topBar: some View { HStack(spacing: 14) { syncButton - searchIndicator + if store.source != .pasteStack { searchIndicator } PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + pasteStackButton moreMenu } .padding(.horizontal, 18) @@ -86,10 +96,32 @@ struct BarView: View { .fixedSize() } + private var pasteStackButton: some View { + Button { + AppController.shared.newPasteStack() + } 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) { LazyHStack(spacing: Theme.cardSpacing) { + if showsStackDeck { + 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 ClipCardView(item: item, index: index, @@ -111,7 +143,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..a82ada0 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// 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 { + 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(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) } + 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(statusText) + .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(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 new file mode 100644 index 0000000..64e9fa2 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -0,0 +1,382 @@ +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.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("Hide 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.displayEntries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: false) + } + } + .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.pendingCount == 0) + + Text(Settings.shared.sequenceHotkeyDisplay) + .font(.caption.weight(.medium)) + .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") + } + .buttonStyle(.borderless) + .help("Clear Paste Stack") + } + } + .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" + } + 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) { + Text("\(index)") + .font(.caption.weight(.bold)) + .foregroundStyle(entry.item.type.accent) + .frame(width: 18) + preview + 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) + 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(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 + 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 ?? ClipboardStore.shared.loadImage(for: entry.item) + } + 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") + } + + 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) + } + 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 { + 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") + } + + 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) + + 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" + } + + 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)" + } +} 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) + } +} 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)