diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..e0c551b 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,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() @@ -167,6 +171,139 @@ 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() { + 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 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 +341,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 +357,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..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..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 ?? [] } @@ -85,7 +91,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 +100,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 +108,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..2fe70af --- /dev/null +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -0,0 +1,139 @@ +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? + /// 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 + self.imagePreview = imagePreview + } +} + +@Observable +@MainActor +final class PasteSequence { + static let shared = 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(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 + 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)) + 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 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 + 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 c37985b..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,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,28 @@ 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) { 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, @@ -111,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 new file mode 100644 index 0000000..464becb --- /dev/null +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -0,0 +1,355 @@ +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 } + 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) + } + 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" + } +} 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)