From 622e8f679c4a5944509892f9ca58aba005532ba4 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:19:23 -0700 Subject: [PATCH 01/44] Resize visible bar when height changes --- Sources/Pesty/AppController.swift | 4 ++++ Sources/Pesty/Settings/Settings.swift | 1 + Sources/Pesty/UI/BarWindowController.swift | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..28be28e 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -150,6 +150,10 @@ final class AppController: NSObject, NSApplicationDelegate { barController?.hide() } + func resizeVisibleBar(to height: Double) { + barController?.resize(to: CGFloat(height)) + } + func pasteSelected() { guard let item = store.selectedItem else { return } hideBar() diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..1eb7082 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -65,6 +65,7 @@ final class Settings { let clamped = min(720, max(240, barHeight)) if clamped != barHeight { barHeight = clamped; return } d.set(barHeight, forKey: Keys.barHeight) + AppController.shared.resizeVisibleBar(to: barHeight) } } diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..5b0969b 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -68,6 +68,18 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { }) } + /// Updates the open panel immediately so dragging the resize handle feels + /// attached to the bar instead of merely changing a future preference. + func resize(to height: CGFloat) { + guard let panel = window, panel.isVisible else { return } + guard let screen = panel.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(panel.frame) }) + ?? NSScreen.main else { return } + let visible = screen.visibleFrame + panel.setFrame(NSRect(x: visible.minX, y: visible.minY, + width: visible.width, height: height), display: true) + } + func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } AppController.shared.hideBar() From ae6b9c247e403fde91cd8e948b907b5c96153c1a Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:21:24 -0700 Subject: [PATCH 02/44] Hide sync control when sync is disabled --- Sources/Pesty/UI/BarView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..7440aec 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -21,7 +21,9 @@ struct BarView: View { private var topBar: some View { HStack(spacing: 14) { - syncButton + if settings.iCloudSync { + syncButton + } searchIndicator PinboardTabs() .layoutPriority(1) From 043e1ada1dfc40bff2d6c5f70a296911f4863764 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:42:21 -0700 Subject: [PATCH 03/44] Use sidebar navigation in Settings --- Sources/Pesty/AppController.swift | 2 +- Sources/Pesty/Settings/SettingsView.swift | 80 +++++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..319c8ab 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -178,7 +178,7 @@ final class AppController: NSObject, NSApplicationDelegate { let win = NSWindow(contentViewController: host) win.title = "Pesty Settings" win.styleMask = [.titled, .closable, .miniaturizable] - win.setContentSize(NSSize(width: 520, height: 560)) + win.setContentSize(NSSize(width: 680, height: 560)) win.center() win.isReleasedWhenClosed = false settingsWindow = win diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..5ec5f9e 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -2,14 +2,82 @@ import SwiftUI import AppKit struct SettingsView: View { + private enum Section: String, CaseIterable, Identifiable { + case general + case about + + var id: Self { self } + + var title: String { + switch self { + case .general: "General" + case .about: "About" + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .about: "info.circle" + } + } + } + + @State private var section: Section = .general + var body: some View { - TabView { - GeneralSettings() - .tabItem { Label("General", systemImage: "gearshape") } - AboutView() - .tabItem { Label("About", systemImage: "info.circle") } + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(nsImage: NSApp.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 30, height: 30) + Text("Pesty") + .font(.headline) + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 14) + + ForEach(Section.allCases) { item in + Button { + section = item + } label: { + Label(item.title, systemImage: item.symbol) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(section == item ? .primary : .secondary) + .background { + if section == item { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary) + } + } + } + + Spacer() + } + .padding(.horizontal, 8) + .frame(width: 180) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + Group { + switch section { + case .general: + GeneralSettings() + case .about: + AboutView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 520, height: 560) + .frame(width: 680, height: 560) } } From c84455460675617b8db40253241d2559accb2f83 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:48:54 -0700 Subject: [PATCH 04/44] Add Quick Look previews for clips --- Sources/Pesty/AppController.swift | 12 ++- Sources/Pesty/UI/BarWindowController.swift | 8 +- Sources/Pesty/Util/QuickLookService.swift | 112 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 Sources/Pesty/Util/QuickLookService.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..afe6740 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -211,6 +211,9 @@ final class AppController: NSObject, NSApplicationDelegate { } switch code { + case kVK_Space where store.searchText.isEmpty: + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } else { hideBar() } @@ -218,9 +221,9 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_Return, kVK_ANSI_KeypadEnter: pasteSelected(); return nil case kVK_LeftArrow, kVK_UpArrow: - store.moveSelection(by: -1); return nil + moveBarSelection(by: -1); return nil case kVK_RightArrow, kVK_DownArrow: - store.moveSelection(by: 1); return nil + moveBarSelection(by: 1); return nil case kVK_Delete: if cmd, let sel = store.selectedItem { store.delete(sel); return nil } if !store.searchText.isEmpty { @@ -244,6 +247,11 @@ final class AppController: NSObject, NSApplicationDelegate { } return event } + + private func moveBarSelection(by delta: Int) { + store.moveSelection(by: delta) + QuickLookService.shared.updateSelection(selectedID: store.selectedID) + } } extension Bundle { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..3661525 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -70,6 +70,12 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } - AppController.shared.hideBar() + // Quick Look becomes key immediately after the strip hands it a preview. + // Defer until that transition is visible before deciding whether focus + // actually left Pesty. + DispatchQueue.main.async { + guard !QuickLookService.shared.isVisible else { return } + AppController.shared.hideBar() + } } } diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift new file mode 100644 index 0000000..f6f286c --- /dev/null +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -0,0 +1,112 @@ +import AppKit +@preconcurrency import QuickLookUI + +@MainActor +final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = QuickLookService() + + private var previewItems: [PreviewItem] = [] + private var startIndexByClipID: [UUID: Int] = [:] + private let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("Pesty-QuickLook", isDirectory: true) + + private override init() {} + + var isVisible: Bool { QLPreviewPanel.shared()?.isVisible ?? false } + + func toggle(items: [ClipItem], selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared() else { return } + if panel.isVisible { + panel.orderOut(nil) + return + } + + prepareTemporaryDirectory() + var selectedIndex = 0 + var newItems: [PreviewItem] = [] + var newStartIndexes: [UUID: Int] = [:] + for clip in items { + let startIndex = newItems.count + newItems.append(contentsOf: previewItems(for: clip)) + if startIndex < newItems.count { newStartIndexes[clip.id] = startIndex } + if clip.id == selectedID, startIndex < newItems.count { selectedIndex = startIndex } + } + guard !newItems.isEmpty else { return } + + previewItems = newItems + startIndexByClipID = newStartIndexes + panel.dataSource = self + panel.reloadData() + panel.currentPreviewItemIndex = selectedIndex + panel.makeKeyAndOrderFront(nil) + } + + func updateSelection(selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared(), panel.isVisible, + let selectedID, let index = startIndexByClipID[selectedID] else { return } + panel.currentPreviewItemIndex = index + } + + func numberOfPreviewItems(in panel: QLPreviewPanel) -> Int { previewItems.count } + + func previewPanel(_ panel: QLPreviewPanel, previewItemAt index: Int) -> QLPreviewItem { + previewItems[index] + } + + private func previewItems(for clip: ClipItem) -> [PreviewItem] { + switch clip.type { + case .file: + let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: $0.lastPathComponent) } } + case .image: + if let url = ClipboardStore.shared.imageURL(for: clip) { + return [PreviewItem(url: url, title: clip.displayTitle)] + } + case .richText: + if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { + return [PreviewItem(url: url, title: clip.displayTitle)] + } + case .color: + let hex = clip.colorHex ?? "#000000" + let html = "\(hex)" + if let url = write(Data(html.utf8), named: "Color \(hex)", extension: "html") { + return [PreviewItem(url: url, title: hex)] + } + case .text, .link: + break + } + + let text = clip.text ?? clip.displayTitle + guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } + return [PreviewItem(url: url, title: clip.displayTitle)] + } + + private func prepareTemporaryDirectory() { + try? FileManager.default.removeItem(at: temporaryDirectory) + try? FileManager.default.createDirectory(at: temporaryDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + private func write(_ data: Data, named title: String, extension fileExtension: String) -> URL? { + let safeTitle = title.replacingOccurrences(of: "/", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseName = safeTitle.isEmpty ? "Clip" : String(safeTitle.prefix(80)) + let filename = "\(baseName)-\(UUID().uuidString).\(fileExtension)" + let url = temporaryDirectory.appendingPathComponent(filename) + do { + try data.write(to: url, options: .atomic) + return url + } catch { return nil } + } +} + +private final class PreviewItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String) { + previewItemURL = url + previewItemTitle = title + } +} From 2f9b0c8ebd149353129bbcae9e7c7f8265a06680 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:56:44 -0700 Subject: [PATCH 05/44] Add Paste Stack core workflow --- Sources/Pesty/AppController.swift | 51 +++++++ Sources/Pesty/Hotkey/HotKeyCenter.swift | 41 ++++-- Sources/Pesty/Monitor/ClipboardMonitor.swift | 3 +- Sources/Pesty/Monitor/PasteService.swift | 11 +- .../Pesty/Settings/HotkeyRecorderView.swift | 14 +- Sources/Pesty/Settings/Settings.swift | 20 +++ Sources/Pesty/Settings/SettingsView.swift | 15 +- Sources/Pesty/Store/ClipboardStore.swift | 6 +- Sources/Pesty/Store/PasteSequence.swift | 57 ++++++++ Sources/Pesty/UI/BarView.swift | 14 ++ Sources/Pesty/UI/PasteStackView.swift | 130 ++++++++++++++++++ .../Pesty/UI/PasteStackWindowController.swift | 47 +++++++ 12 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 Sources/Pesty/Store/PasteSequence.swift create mode 100644 Sources/Pesty/UI/PasteStackView.swift create mode 100644 Sources/Pesty/UI/PasteStackWindowController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..a4f2a26 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -8,14 +8,17 @@ final class AppController: NSObject, NSApplicationDelegate { let store = ClipboardStore.shared let monitor = ClipboardMonitor() + let pasteSequence = PasteSequence.shared private var barController: BarWindowController? private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? + private var pasteStackController: PasteStackWindowController? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? + private var pasteStackTargetApp: NSRunningApplication? var suppressAutoHide = false @@ -29,6 +32,7 @@ final class AppController: NSObject, NSApplicationDelegate { monitor.start() HotKeyCenter.shared.onTrigger = { [weak self] in self?.toggleBar() } + HotKeyCenter.shared.onSequenceTrigger = { [weak self] in self?.pasteNextInSequence() } HotKeyCenter.shared.start() setupStatusItem() @@ -167,6 +171,53 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func beginPasteSequence() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.begin() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func showPasteStack() { + if pasteStackController == nil { + pasteStackController = PasteStackWindowController() + } + pasteStackController?.show() + } + + func cancelPasteSequence() { + pasteSequence.cancel() + pasteStackController?.hide() + pasteStackTargetApp = nil + } + + func capturePasteStackItem(_ item: ClipItem) { + _ = pasteSequence.addIfNeeded(item) + } + + func pasteNextInSequence() { + guard pasteSequence.hasEntries else { return } + + #if !MAS + guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } + #endif + + guard let entry = pasteSequence.next() else { return } + PasteService.paste(entry.item, + into: pasteStackTargetApp ?? previousApp, + monitor: monitor, + imageOverride: entry.imagePreview) + if !pasteSequence.hasEntries { + pasteStackController?.hide() + pasteStackTargetApp = nil + } + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { diff --git a/Sources/Pesty/Hotkey/HotKeyCenter.swift b/Sources/Pesty/Hotkey/HotKeyCenter.swift index 1014327..14a4522 100644 --- a/Sources/Pesty/Hotkey/HotKeyCenter.swift +++ b/Sources/Pesty/Hotkey/HotKeyCenter.swift @@ -6,8 +6,10 @@ final class HotKeyCenter { static let shared = HotKeyCenter() var onTrigger: (() -> Void)? + var onSequenceTrigger: (() -> Void)? private var hotKeyRef: EventHotKeyRef? + private var sequenceHotKeyRef: EventHotKeyRef? private var handlerRef: EventHandlerRef? private let signature: OSType = 0x50535459 @@ -22,25 +24,48 @@ final class HotKeyCenter { guard handlerRef == nil else { return } var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed)) - InstallEventHandler(GetApplicationEventTarget(), { _, _, _ -> OSStatus in - DispatchQueue.main.async { HotKeyCenter.shared.onTrigger?() } + InstallEventHandler(GetApplicationEventTarget(), { _, event, _ -> OSStatus in + var id = EventHotKeyID() + GetEventParameter(event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &id) + DispatchQueue.main.async { + if id.id == 2 { + HotKeyCenter.shared.onSequenceTrigger?() + } else { + HotKeyCenter.shared.onTrigger?() + } + } return noErr }, 1, &spec, nil, &handlerRef) } func reload() { unregister() - let keyCode = UInt32(Settings.shared.hotkeyKeyCode) - let modifiers = UInt32(Settings.shared.hotkeyModifiers) - guard keyCode != 0 else { return } - let id = EventHotKeyID(signature: signature, id: 1) + hotKeyRef = register(keyCode: Settings.shared.hotkeyKeyCode, + modifiers: Settings.shared.hotkeyModifiers, + id: 1) + sequenceHotKeyRef = register(keyCode: Settings.shared.sequenceHotkeyKeyCode, + modifiers: Settings.shared.sequenceHotkeyModifiers, + id: 2) + } + + private func register(keyCode: Int, modifiers: Int, id: UInt32) -> EventHotKeyRef? { + guard keyCode != 0 else { return nil } + let hotKeyID = EventHotKeyID(signature: signature, id: id) var ref: EventHotKeyRef? - let status = RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &ref) - if status == noErr { hotKeyRef = ref } + let status = RegisterEventHotKey(UInt32(keyCode), UInt32(modifiers), hotKeyID, + GetApplicationEventTarget(), 0, &ref) + return status == noErr ? ref : nil } private func unregister() { if let ref = hotKeyRef { UnregisterEventHotKey(ref); hotKeyRef = nil } + if let ref = sequenceHotKeyRef { UnregisterEventHotKey(ref); sequenceHotKeyRef = nil } } static func describe(keyCode: Int, modifiers: Int) -> String { diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..95aa4ac 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -30,7 +30,8 @@ final class ClipboardMonitor { lastChangeCount = current if current == suppressUntilChangeCount { return } guard let item = makeItem() else { return } - ClipboardStore.shared.addCaptured(item) + let storedItem = ClipboardStore.shared.addCaptured(item) + AppController.shared.capturePasteStackItem(storedItem) } private func makeItem() -> ClipItem? { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..33d6228 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,9 +5,11 @@ import Carbon.HIToolbox enum PasteService { @discardableResult - static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int { + static func copy(_ item: ClipItem, + to pasteboard: NSPasteboard = .general, + imageOverride: NSImage? = nil) -> Int { if item.type == .image { - guard let img = ClipboardStore.shared.loadImage(for: item) else { + guard let img = imageOverride ?? ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount } pasteboard.clearContents() @@ -38,8 +40,9 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + imageOverride: NSImage? = nil) { + let change = copy(item, imageOverride: imageOverride) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } diff --git a/Sources/Pesty/Settings/HotkeyRecorderView.swift b/Sources/Pesty/Settings/HotkeyRecorderView.swift index 056c223..8060e90 100644 --- a/Sources/Pesty/Settings/HotkeyRecorderView.swift +++ b/Sources/Pesty/Settings/HotkeyRecorderView.swift @@ -3,15 +3,21 @@ import AppKit import Carbon.HIToolbox struct HotkeyRecorderView: View { - @Bindable private var settings = Settings.shared + @Binding private var keyCode: Int + @Binding private var modifiers: Int @State private var recording = false @State private var monitor: Any? + init(keyCode: Binding, modifiers: Binding) { + _keyCode = keyCode + _modifiers = modifiers + } + var body: some View { Button { recording ? stop() : start() } label: { - Text(recording ? "Press keys…" : settings.hotkeyDisplay) + Text(recording ? "Press keys…" : HotKeyCenter.describe(keyCode: keyCode, modifiers: modifiers)) .font(.system(size: 13, weight: .medium, design: .rounded)) .frame(minWidth: 90) .padding(.horizontal, 12).padding(.vertical, 5) @@ -34,8 +40,8 @@ struct HotkeyRecorderView: View { if mods & (cmdKey | controlKey | optionKey) == 0 { NSSound.beep(); return nil } - settings.hotkeyKeyCode = Int(event.keyCode) - settings.hotkeyModifiers = mods + keyCode = Int(event.keyCode) + modifiers = mods stop() return nil } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..7b27845 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -14,6 +14,8 @@ final class Settings { static let historyLimit = "historyLimit" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" + static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" + static let sequenceHotkeyModifiers = "sequenceHotkeyModifiers" static let launchAtLogin = "launchAtLogin" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" @@ -42,6 +44,16 @@ final class Settings { d.set(hotkeyModifiers, forKey: Keys.hotkeyModifiers); HotKeyCenter.shared.reload() } } + var sequenceHotkeyKeyCode: Int { + didSet { guard isLoaded else { return } + d.set(sequenceHotkeyKeyCode, forKey: Keys.sequenceHotkeyKeyCode); HotKeyCenter.shared.reload() } + } + + var sequenceHotkeyModifiers: Int { + didSet { guard isLoaded else { return } + d.set(sequenceHotkeyModifiers, forKey: Keys.sequenceHotkeyModifiers); HotKeyCenter.shared.reload() } + } + var launchAtLogin: Bool { didSet { guard isLoaded else { return } d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } @@ -81,6 +93,8 @@ final class Settings { Keys.historyLimit: 500, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, + Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, + Keys.sequenceHotkeyModifiers: cmdKey | optionKey, Keys.launchAtLogin: false, Keys.pasteDirectly: true, Keys.playSound: false, @@ -92,6 +106,8 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) + sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) + sequenceHotkeyModifiers = d.integer(forKey: Keys.sequenceHotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) @@ -105,4 +121,8 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + var sequenceHotkeyDisplay: String { + HotKeyCenter.describe(keyCode: sequenceHotkeyKeyCode, modifiers: sequenceHotkeyModifiers) + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..fa0921e 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -25,12 +25,25 @@ private struct GeneralSettings: View { var body: some View { Form { Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } + LabeledContent("Show Pesty") { + HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode, + modifiers: $settings.hotkeyModifiers) + } Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { LabeledContent("History limit", value: "\(settings.historyLimit) items") } } + Section("Paste Stack") { + LabeledContent("Paste next clip") { + HotkeyRecorderView(keyCode: $settings.sequenceHotkeyKeyCode, + modifiers: $settings.sequenceHotkeyModifiers) + } + Text("Start a Paste Stack from the strip, then copy clips in another app. Use this shortcut to paste each clip in order.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..aca9d3a 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -85,7 +85,8 @@ final class ClipboardStore { return visibleItems.first(where: { $0.id == id }) } - func addCaptured(_ item: ClipItem) { + @discardableResult + func addCaptured(_ item: ClipItem) -> ClipItem { if let idx = history.firstIndex(where: { $0.sameContent(as: item) }) { if item.imageFileName != history[idx].imageFileName { deleteImageFile(item) } var existing = history.remove(at: idx) @@ -93,7 +94,7 @@ final class ClipboardStore { history.insert(existing, at: 0) if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() - return + return existing } history.insert(item, at: 0) trimHistory() @@ -101,6 +102,7 @@ final class ClipboardStore { selectedID = item.id } scheduleSave() + return item } func applyHistoryLimit() { trimHistory(); scheduleSave() } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift new file mode 100644 index 0000000..b9ab73b --- /dev/null +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -0,0 +1,57 @@ +import AppKit +import Observation + +struct PasteStackEntry: Identifiable { + let id = UUID() + let item: ClipItem + /// Holds an in-memory image while a Stack is active, even if the image is + /// later pruned from clipboard history. + let imagePreview: NSImage? + + init(item: ClipItem, imagePreview: NSImage? = nil) { + self.item = item + self.imagePreview = imagePreview + } +} + +@Observable +@MainActor +final class PasteSequence { + static let shared = PasteSequence() + + private(set) var entries: [PasteStackEntry] = [] + private(set) var isCollecting = false + + var hasEntries: Bool { !entries.isEmpty } + var pendingCount: Int { entries.count } + + private init() {} + + func begin() { + entries.removeAll() + isCollecting = true + } + + @discardableResult + func addIfNeeded(_ item: ClipItem) -> Bool { + guard isCollecting, + !entries.contains(where: { $0.item.id == item.id }) else { return false } + let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil + entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) + return true + } + + func next() -> PasteStackEntry? { + guard !entries.isEmpty else { + isCollecting = false + return nil + } + isCollecting = false + return entries.removeFirst() + } + + func cancel() { + entries.removeAll() + isCollecting = false + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..b7f77f5 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -26,6 +26,7 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + pasteStackButton moreMenu } .padding(.horizontal, 18) @@ -86,6 +87,19 @@ struct BarView: View { .fixedSize() } + private var pasteStackButton: some View { + Button { + AppController.shared.beginPasteSequence() + } label: { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help("Start Paste Stack") + } + private var strip: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift new file mode 100644 index 0000000..4843d83 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct PasteStackView: View { + @Bindable private var stack = PasteSequence.shared + + var body: some View { + VStack(spacing: 0) { + header + Divider() + entries + Divider() + footer + } + .frame(width: 320, height: 380) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.25)) + } + } + + private var header: some View { + HStack(spacing: 9) { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + AppController.shared.cancelPasteSequence() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel Paste Stack") + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(Theme.selection) + Text(stack.isCollecting + ? "Copy text, images, or files in another app to collect them here." + : "Start a new Paste Stack from the strip.") + .font(.system(size: 12, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 210) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 7) { + ForEach(Array(stack.entries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, index: index + 1) + } + } + .padding(12) + } + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button { + AppController.shared.pasteNextInSequence() + } label: { + Label("Paste Next", systemImage: "doc.on.clipboard") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!stack.hasEntries) + + Text(Settings.shared.sequenceHotkeyDisplay) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 15) + .frame(height: 52) + } + + private var summary: String { + if stack.isCollecting { + return stack.hasEntries ? "\(stack.pendingCount) collected - copy more" : "Copy clips in another app" + } + return stack.hasEntries ? "\(stack.pendingCount) ready to paste" : "Stack complete" + } +} + +private struct PasteStackEntryRow: View { + let entry: PasteStackEntry + let index: Int + + var body: some View { + HStack(spacing: 10) { + Text("\(index)") + .font(.caption.weight(.bold)) + .foregroundStyle(entry.item.type.accent) + .frame(width: 18) + Image(systemName: entry.item.type.symbol) + .foregroundStyle(entry.item.type.accent) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Text(entry.item.type.label) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} diff --git a/Sources/Pesty/UI/PasteStackWindowController.swift b/Sources/Pesty/UI/PasteStackWindowController.swift new file mode 100644 index 0000000..055799f --- /dev/null +++ b/Sources/Pesty/UI/PasteStackWindowController.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +final class PasteStackPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +@MainActor +final class PasteStackWindowController: NSWindowController { + init() { + let panel = PasteStackPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 380), + styleMask: [.borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .modalPanel + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: PasteStackView()) + super.init(window: panel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + func show() { + guard let panel = window else { return } + let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return } + + let visible = screen.visibleFrame + let origin = NSPoint(x: visible.maxX - panel.frame.width - 22, + y: visible.maxY - panel.frame.height - 22) + panel.setFrameOrigin(origin) + panel.orderFrontRegardless() + panel.makeKey() + } + + func hide() { + window?.orderOut(nil) + } +} From abd05c77413a246a11ebf129303c7812c6da3c31 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:59:28 -0700 Subject: [PATCH 06/44] Show previews and source apps in Paste Stack --- Sources/Pesty/UI/PasteStackView.swift | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 4843d83..65ab4fa 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -110,9 +110,7 @@ private struct PasteStackEntryRow: View { .font(.caption.weight(.bold)) .foregroundStyle(entry.item.type.accent) .frame(width: 18) - Image(systemName: entry.item.type.symbol) - .foregroundStyle(entry.item.type.accent) - .frame(width: 20) + preview VStack(alignment: .leading, spacing: 2) { Text(entry.item.displayTitle) .font(.system(size: 12, weight: .medium)) @@ -122,9 +120,50 @@ private struct PasteStackEntryRow: View { .foregroundStyle(.secondary) } Spacer(minLength: 0) + sourceAppIcon } .padding(.horizontal, 10) .padding(.vertical, 9) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } + + @ViewBuilder + private var preview: some View { + if let image = previewImage { + Image(nsImage: image) + .resizable() + .interpolation(.medium) + .scaledToFill() + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 38, height: 38) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + } + } + + private var previewImage: NSImage? { + if entry.item.type == .image { return entry.imagePreview } + guard entry.item.type == .file, + entry.item.fileURLs.count == 1, + let urlString = entry.item.fileURLs.first, + let url = URL(string: urlString), + url.isFileURL else { return nil } + return NSImage(contentsOf: url) + } + + private var sourceAppIcon: some View { + Image(nsImage: AppIconProvider.icon(forBundleID: entry.item.sourceBundleID)) + .resizable() + .interpolation(.high) + .frame(width: 19, height: 19) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .help(entry.item.sourceAppName ?? "Source app") + } } From 17929d7d0650a1d6e166e7336e5bbc76be155bef Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 20:45:37 -0700 Subject: [PATCH 07/44] Add optional Pesty bar resize handle --- Sources/Pesty/Settings/Settings.swift | 7 +++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/BarView.swift | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 1eb7082..d52e860 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -19,6 +19,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -69,6 +70,10 @@ final class Settings { } } + var showBarResizeHandle: Bool { + didSet { guard isLoaded else { return }; d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -87,6 +92,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.showBarResizeHandle: false, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -98,6 +104,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..b61a7c0 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -38,6 +38,7 @@ private struct GeneralSettings: View { Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) Toggle("Launch at login", isOn: $settings.launchAtLogin) + Toggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..d338362 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,6 +3,7 @@ import SwiftUI struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + @State private var resizeStartHeight: Double? var body: some View { ZStack { @@ -11,6 +12,7 @@ struct BarView: View { } .overlay(alignment: .top) { VStack(spacing: 0) { + if settings.showBarResizeHandle { resizeHandle } topBar strip } @@ -19,6 +21,27 @@ struct BarView: View { .ignoresSafeArea() } + private var resizeHandle: some View { + HStack { + Capsule(style: .continuous) + .fill(Theme.textTertiary.opacity(0.7)) + .frame(width: 42, height: 4) + } + .frame(maxWidth: .infinity) + .frame(height: 14) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if resizeStartHeight == nil { resizeStartHeight = settings.barHeight } + guard let start = resizeStartHeight else { return } + settings.barHeight = min(720, max(300, start - value.translation.height)) + } + .onEnded { _ in resizeStartHeight = nil } + ) + .help("Drag to resize the Pesty bar") + } + private var topBar: some View { HStack(spacing: 14) { syncButton From 30cfff47e0f10d92c3a7314bf5cb62b8aeb9bdd9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:58:13 -0700 Subject: [PATCH 08/44] Render text and links richly in Quick Look --- Sources/Pesty/Util/QuickLookService.swift | 57 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift index f6f286c..0479013 100644 --- a/Sources/Pesty/Util/QuickLookService.swift +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -57,14 +57,14 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource switch clip.type { case .file: let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) - if !files.isEmpty { return files.map { PreviewItem(url: $0, title: $0.lastPathComponent) } } + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.type.label) } } case .image: if let url = ClipboardStore.shared.imageURL(for: clip) { - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } case .richText: if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } case .color: let hex = clip.colorHex ?? "#000000" @@ -72,13 +72,21 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource if let url = write(Data(html.utf8), named: "Color \(hex)", extension: "html") { return [PreviewItem(url: url, title: hex)] } - case .text, .link: - break + case .text: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text).utf8), named: "Text", extension: "html") { + return [PreviewItem(url: url, title: clip.type.label)] + } + case .link: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text, isLink: true).utf8), named: "Link", extension: "html") { + return [PreviewItem(url: url, title: clip.type.label)] + } } let text = clip.text ?? clip.displayTitle guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } private func prepareTemporaryDirectory() { @@ -99,6 +107,43 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource return url } catch { return nil } } + + /// Use a local, self-contained document so Quick Look can present text and + /// links more readably without loading remote content or running scripts. + private func textPreviewHTML(for text: String, isLink: Bool = false) -> String { + let characterCount = text.count + let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count + let lineCount = max(1, text.split(separator: "\n", omittingEmptySubsequences: false).count) + let escaped = escapeHTML(text) + let content = isLink ? "
\(escaped)
" : "
\(escaped)
" + let words = wordCount == 1 ? "word" : "words" + let lines = lineCount == 1 ? "line" : "lines" + + return """ + + +
\(content)
+
\(characterCount) characters·\(wordCount) \(words)·\(lineCount) \(lines)
+ + """ + } + + private func escapeHTML(_ text: String) -> String { + text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } } private final class PreviewItem: NSObject, QLPreviewItem { From ac624e1057895833ca399c70885542d775403dde Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:11:26 -0700 Subject: [PATCH 09/44] Add configurable clip previews --- Sources/Pesty/AppController.swift | 8 +- Sources/Pesty/Settings/Settings.swift | 28 ++++ Sources/Pesty/Settings/SettingsView.swift | 12 ++ Sources/Pesty/Store/ClipboardStore.swift | 1 + Sources/Pesty/UI/BarView.swift | 22 ++- Sources/Pesty/UI/ClipPreviewViews.swift | 193 ++++++++++++++++++++++ Sources/Pesty/Util/LinkPreviewStore.swift | 77 +++++++++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 Sources/Pesty/UI/ClipPreviewViews.swift create mode 100644 Sources/Pesty/Util/LinkPreviewStore.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index afe6740..e8135e5 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -137,6 +137,7 @@ final class AppController: NSObject, NSApplicationDelegate { store.searchText = "" store.source = .history store.selectFirst() + store.inlinePreviewVisible = false if barController == nil { barController = BarWindowController() @@ -147,6 +148,7 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() + store.inlinePreviewVisible = false barController?.hide() } @@ -212,7 +214,11 @@ final class AppController: NSObject, NSApplicationDelegate { switch code { case kVK_Space where store.searchText.isEmpty: - QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + if Settings.shared.clipPreviewStyle == .nativeQuickLook { + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + } else if store.selectedItem != nil { + store.inlinePreviewVisible.toggle() + } return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..2ef2586 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,27 @@ import AppKit import Carbon.HIToolbox import Observation +enum ClipPreviewStyle: Int, CaseIterable, Identifiable { + case nativeQuickLook + case inlinePesty + + var id: Int { rawValue } + + var title: String { + switch self { + case .nativeQuickLook: "Native Quick Look" + case .inlinePesty: "Inline Pesty preview" + } + } + + var detail: String { + switch self { + case .nativeQuickLook: "Open a macOS Quick Look panel with Space." + case .inlinePesty: "Show a rich preview with link titles and favicons inside Pesty." + } + } +} + @Observable @MainActor final class Settings { @@ -19,6 +40,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let clipPreviewStyle = "clipPreviewStyle" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -68,6 +90,10 @@ final class Settings { } } + var clipPreviewStyle: ClipPreviewStyle { + didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -86,6 +112,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -97,6 +124,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..65226f7 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -48,6 +48,18 @@ private struct GeneralSettings: View { #endif } + Section("Clip previews") { + Picker("Preview style", selection: $settings.clipPreviewStyle) { + ForEach(ClipPreviewStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sync") { Toggle("Sync clipboard via iCloud Drive", isOn: Binding( get: { settings.iCloudSync }, diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..b02a403 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -17,6 +17,7 @@ final class ClipboardStore { var source: BarSource = .history var searchText: String = "" var selectedID: UUID? + var inlinePreviewVisible = false var historyLimit: Int { get { Settings.shared.historyLimit } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..f2c13c7 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -12,7 +12,15 @@ struct BarView: View { .overlay(alignment: .top) { VStack(spacing: 0) { topBar - strip + HStack(spacing: 0) { + if settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible, + let item = store.selectedItem { + SelectedClipPreviewView(item: item) + Divider() + } + strip + } } } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) @@ -26,12 +34,24 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if settings.clipPreviewStyle == .inlinePesty { previewButton } moreMenu } .padding(.horizontal, 18) .frame(height: 56) } + private var previewButton: some View { + Button { store.inlinePreviewVisible.toggle() } label: { + Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help(store.inlinePreviewVisible ? "Hide clip preview" : "Show clip preview") + } + private var syncButton: some View { Button { AppController.shared.toggleICloudSync() diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift new file mode 100644 index 0000000..438c16f --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -0,0 +1,193 @@ +import AppKit +import SwiftUI + +struct RichTextContent: View { + let rtfData: Data? + let fallback: String + var font: Font = .system(size: 13) + var lineLimit: Int? = nil + + var body: some View { + Group { + if let richText { + Text(richText) + } else { + Text(fallback) + } + } + .font(font) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + } + + private var richText: AttributedString? { + guard let rtfData, + let value = try? NSAttributedString(data: rtfData, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil) else { return nil } + return AttributedString(value) + } +} + +struct LinkPreviewContent: View { + let text: String + let compact: Bool + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + HStack(spacing: compact ? 8 : 12) { + icon + VStack(alignment: .leading, spacing: compact ? 2 : 5) { + Text(preview?.title ?? host) + .font(.system(size: compact ? 12 : 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(compact ? 2 : 3) + Text(host) + .font(.system(size: compact ? 10 : 12)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .onAppear { previews.load(for: url) } + } + + @ViewBuilder + private var icon: some View { + if let image = preview?.icon { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .clipShape(RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .overlay { + Image(systemName: "link") + .font(.system(size: compact ? 12 : 17, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + } +} + +struct SelectedClipPreviewView: View { + let item: ClipItem + private var store: ClipboardStore { ClipboardStore.shared } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: item.type.symbol) + .foregroundStyle(item.type.accent) + Text(item.type.label) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Theme.textSecondary) + Spacer() + Text(item.createdAt.clipRelativeLong) + .font(.system(size: 11)) + .foregroundStyle(Theme.textTertiary) + } + previewContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + Text(item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(2) + } + .padding(16) + .frame(width: 340) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.58)) + } + + @ViewBuilder + private var previewContent: some View { + switch item.type { + case .image: + if let image = store.loadImage(for: item) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { missingPreview("photo") } + case .richText: + ScrollView { + RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", font: .system(size: 15)) + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + case .link: + VStack(spacing: 14) { + LinkPreviewContent(text: item.text ?? item.displayTitle, compact: false) + Text(item.text ?? "") + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + .lineLimit(3) + Spacer() + } + case .file: + if let image = filePreviewImage { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 46, weight: .light)) + .foregroundStyle(item.type.accent) + Text(item.displayTitle) + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.textPrimary) + Spacer() + } + .frame(maxWidth: .infinity) + } + case .color: + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(hex: item.colorHex ?? "#000") ?? .black) + .overlay { + Text(item.colorHex ?? "") + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .shadow(radius: 2) + } + case .text: + ScrollView { + Text(item.text ?? "") + .font(.system(size: 15)) + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + } + + private func missingPreview(_ symbol: String) -> some View { + Image(systemName: symbol) + .font(.system(size: 38, weight: .light)) + .foregroundStyle(Theme.textTertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var filePreviewImage: NSImage? { + guard item.fileURLs.count == 1, + let value = item.fileURLs.first, + let url = URL(string: value), + url.isFileURL else { return nil } + return NSImage(contentsOf: url) + } +} diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift new file mode 100644 index 0000000..4702bec --- /dev/null +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -0,0 +1,77 @@ +import AppKit +import Foundation +import Observation + +struct LinkPreview { + var title: String? + var icon: NSImage? +} + +@Observable +@MainActor +final class LinkPreviewStore { + static let shared = LinkPreviewStore() + + private var previews: [String: LinkPreview] = [:] + private var loadingHosts: Set = [] + + private init() {} + + func preview(for url: URL?) -> LinkPreview? { + guard let host = url?.host?.lowercased() else { return nil } + return previews[host] + } + + func load(for url: URL?) { + guard let url, + let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), + let host = url.host?.lowercased(), + !loadingHosts.contains(host) else { return } + loadingHosts.insert(host) + previews[host] = previews[host] ?? LinkPreview() + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") + URLSession.shared.dataTask(with: request) { data, _, _ in + let title = data.flatMap(Self.pageTitle(from:)) + DispatchQueue.main.async { + self.update(host: host, title: title, icon: nil, finished: false) + } + }.resume() + + var faviconURL = URLComponents() + faviconURL.scheme = scheme + faviconURL.host = host + faviconURL.path = "/favicon.ico" + guard let iconURL = faviconURL.url else { + loadingHosts.remove(host) + return + } + URLSession.shared.dataTask(with: iconURL) { data, _, _ in + let icon = data.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: icon, finished: true) + } + }.resume() + } + + private func update(host: String, title: String?, icon: NSImage?, finished: Bool) { + var preview = previews[host] ?? LinkPreview() + if let title, !title.isEmpty { preview.title = title } + if let icon { preview.icon = icon } + previews[host] = preview + if finished { loadingHosts.remove(host) } + } + + nonisolated private static func pageTitle(from data: Data) -> String? { + guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), + let expression = try? NSRegularExpression(pattern: "]*>(.*?)", + options: [.caseInsensitive, .dotMatchesLineSeparators]), + let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let range = Range(match.range(at: 1), in: html) else { return nil } + return html[range] + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} From 052e4e2266d5efc6a53f9265ade4466d664acbd6 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:43:32 -0700 Subject: [PATCH 10/44] Use source app icons to color clip cards --- Sources/Pesty/Util/AppIconProvider.swift | 24 ++++++- Sources/Pesty/Util/SourceColor.swift | 88 +++++++++++++++++------- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/Sources/Pesty/Util/AppIconProvider.swift b/Sources/Pesty/Util/AppIconProvider.swift index 987beb5..3535cd6 100644 --- a/Sources/Pesty/Util/AppIconProvider.swift +++ b/Sources/Pesty/Util/AppIconProvider.swift @@ -2,19 +2,41 @@ import AppKit @MainActor enum AppIconProvider { + private static let pestyBundleID = "com.greycorelabs.pesty" private static var cache: [String: NSImage] = [:] static func icon(forBundleID bundleID: String?) -> NSImage { guard let bundleID else { return generic } if let cached = cache[bundleID] { return cached } var image = generic - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + if bundleID == pestyBundleID || bundleID == Bundle.main.bundleIdentifier { + image = pestyIcon() + } else if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { image = NSWorkspace.shared.icon(forFile: url.path) } cache[bundleID] = image return image } + private static func pestyIcon() -> NSImage { + if let url = Bundle.main.url(forResource: "Pesty", withExtension: "icns"), + let icon = NSImage(contentsOf: url) { + return icon + } + let projectRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let developmentIcon = projectRoot.appending(path: "packaging/Pesty.icns") + if let icon = NSImage(contentsOf: developmentIcon) { + return icon + } + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: pestyBundleID) { + return NSWorkspace.shared.icon(forFile: url.path) + } + return NSApp.applicationIconImage ?? generic + } + static let generic: NSImage = NSImage(systemSymbolName: "app.dashed", accessibilityDescription: nil) ?? NSImage() diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..baa6e2e 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,33 +1,71 @@ +import AppKit import SwiftUI @MainActor enum SourceColor { - private static let palette: [Color] = [ - Color(red: 0.85, green: 0.66, blue: 0.22), - Color(red: 0.34, green: 0.56, blue: 0.82), - Color(red: 0.72, green: 0.38, blue: 0.58), - Color(red: 0.27, green: 0.62, blue: 0.55), - Color(red: 0.80, green: 0.40, blue: 0.34), - Color(red: 0.45, green: 0.40, blue: 0.74), - Color(red: 0.49, green: 0.62, blue: 0.30), - Color(red: 0.84, green: 0.52, blue: 0.27), - Color(red: 0.30, green: 0.49, blue: 0.74), - Color(red: 0.62, green: 0.42, blue: 0.30), - Color(red: 0.74, green: 0.36, blue: 0.42), - Color(red: 0.40, green: 0.55, blue: 0.62) - ] - - private static let key = "appColorMap" - private static var map: [String: Int] = { - UserDefaults.standard.dictionary(forKey: key) as? [String: Int] ?? [:] - }() + private static var cache: [String: Color] = [:] + private static let fallback = Color(red: 0.02, green: 0.48, blue: 1.0) static func color(for bundleID: String?) -> Color { - guard let id = bundleID, !id.isEmpty else { return palette[0] } - if let i = map[id] { return palette[i % palette.count] } - let i = map.count % palette.count - map[id] = i - UserDefaults.standard.set(map, forKey: key) - return palette[i] + guard let id = bundleID, !id.isEmpty else { return fallback } + if let color = cache[id] { return color } + let color = dominantColor(in: AppIconProvider.icon(forBundleID: id)) ?? fallback + cache[id] = color + return color + } + + private static func dominantColor(in icon: NSImage) -> Color? { + let size = 40 + let thumbnail = NSImage(size: NSSize(width: size, height: size)) + thumbnail.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + icon.draw(in: NSRect(x: 0, y: 0, width: size, height: size), + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high]) + thumbnail.unlockFocus() + guard let data = thumbnail.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: data) else { return nil } + + var red = 0.0 + var green = 0.0 + var blue = 0.0 + var weight = 0.0 + var darkWeight = 0.0 + + for x in 0.. 0.35 else { continue } + let maximum = max(color.redComponent, color.greenComponent, color.blueComponent) + let minimum = min(color.redComponent, color.greenComponent, color.blueComponent) + let saturation = maximum == 0 ? 0 : (maximum - minimum) / maximum + let brightness = maximum + + if brightness < 0.45 { darkWeight += alpha } + guard saturation > 0.16, brightness > 0.14 else { continue } + let pixelWeight = alpha * saturation * (0.45 + 0.55 * brightness) + red += Double(color.redComponent) * pixelWeight + green += Double(color.greenComponent) * pixelWeight + blue += Double(color.blueComponent) * pixelWeight + weight += pixelWeight + } + } + + if weight == 0 { + return darkWeight > 0 ? Color(red: 0.025, green: 0.075, blue: 0.24) : fallback + } + + let main = NSColor(deviceRed: red / weight, green: green / weight, blue: blue / weight, alpha: 1) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + main.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + return Color(hue: Double(hue), + saturation: min(0.99, max(0.90, Double(saturation) * 1.85)), + brightness: min(0.98, max(0.84, Double(brightness) * 1.20))) } } From 167378f55dca5adf01bf8795562fcd01eae25e25 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:52:51 -0700 Subject: [PATCH 11/44] Configure quick paste modifiers --- Sources/Pesty/AppController.swift | 26 +++++++--- Sources/Pesty/Monitor/PasteService.swift | 14 ++++-- Sources/Pesty/Settings/Settings.swift | 61 +++++++++++++++++++++++ Sources/Pesty/Settings/SettingsView.swift | 26 ++++++++++ Sources/Pesty/UI/ClipCardView.swift | 5 +- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..349692b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -150,15 +150,15 @@ final class AppController: NSObject, NSApplicationDelegate { barController?.hide() } - func pasteSelected() { + func pasteSelected(asPlainText: Bool = false) { guard let item = store.selectedItem else { return } hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + PasteService.paste(item, into: previousApp, monitor: monitor, asPlainText: asPlainText) } - func pasteItem(_ item: ClipItem) { + func pasteItem(_ item: ClipItem, asPlainText: Bool = false) { hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + PasteService.paste(item, into: previousApp, monitor: monitor, asPlainText: asPlainText) } func copyItem(_ item: ClipItem) { @@ -204,9 +204,13 @@ 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 includes(Settings.shared.quickPasteModifier, in: flags), + let chars = event.charactersIgnoringModifiers, + let n = Int(chars), (1...9).contains(n) { let items = store.visibleItems - if n <= items.count { pasteItem(items[n - 1]) } + if n <= items.count { + pasteItem(items[n - 1], asPlainText: includes(Settings.shared.plainTextModifier, in: flags)) + } return nil } @@ -244,6 +248,16 @@ final class AppController: NSObject, NSApplicationDelegate { } return event } + + private func includes(_ carbonModifier: Int, in flags: NSEvent.ModifierFlags) -> Bool { + switch carbonModifier { + case cmdKey: return flags.contains(.command) + case optionKey: return flags.contains(.option) + case controlKey: return flags.contains(.control) + case shiftKey: return flags.contains(.shift) + default: return false + } + } } extension Bundle { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..42a45f9 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,7 +5,14 @@ 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, + asPlainText: Bool = false) -> Int { + if asPlainText, let text = item.text ?? item.colorHex { + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + return pasteboard.changeCount + } if item.type == .image { guard let img = ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount @@ -38,8 +45,9 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + asPlainText: Bool = false) { + let change = copy(item, asPlainText: asPlainText) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..7ac2807 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,49 @@ import AppKit import Carbon.HIToolbox import Observation +enum ShortcutModifier: CaseIterable, Identifiable { + case command + case option + case control + case shift + + var id: Int { carbonValue } + + var carbonValue: Int { + switch self { + case .command: return cmdKey + case .option: return optionKey + case .control: return controlKey + case .shift: return shiftKey + } + } + + var title: String { + switch self { + case .command: return "Command" + case .option: return "Option" + case .control: return "Control" + case .shift: return "Shift" + } + } + + var symbol: String { + switch self { + case .command: return "⌘" + case .option: return "⌥" + case .control: return "⌃" + case .shift: return "⇧" + } + } + + init?(carbonValue: Int) { + guard let modifier = Self.allCases.first(where: { $0.carbonValue == carbonValue }) else { + return nil + } + self = modifier + } +} + @Observable @MainActor final class Settings { @@ -14,6 +57,8 @@ final class Settings { static let historyLimit = "historyLimit" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" + static let quickPasteModifier = "quickPasteModifier" + static let plainTextModifier = "plainTextModifier" static let launchAtLogin = "launchAtLogin" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" @@ -42,6 +87,14 @@ final class Settings { d.set(hotkeyModifiers, forKey: Keys.hotkeyModifiers); HotKeyCenter.shared.reload() } } + var quickPasteModifier: Int { + didSet { guard isLoaded else { return }; d.set(quickPasteModifier, forKey: Keys.quickPasteModifier) } + } + + var plainTextModifier: Int { + didSet { guard isLoaded else { return }; d.set(plainTextModifier, forKey: Keys.plainTextModifier) } + } + var launchAtLogin: Bool { didSet { guard isLoaded else { return } d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } @@ -81,6 +134,8 @@ final class Settings { Keys.historyLimit: 500, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, + Keys.quickPasteModifier: ShortcutModifier.command.carbonValue, + Keys.plainTextModifier: ShortcutModifier.shift.carbonValue, Keys.launchAtLogin: false, Keys.pasteDirectly: true, Keys.playSound: false, @@ -92,6 +147,8 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) + quickPasteModifier = d.integer(forKey: Keys.quickPasteModifier) + plainTextModifier = d.integer(forKey: Keys.plainTextModifier) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) @@ -105,4 +162,8 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + var quickPasteModifierDisplay: String { + ShortcutModifier(carbonValue: quickPasteModifier)?.symbol ?? "⌘" + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..09ad14b 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -31,6 +31,21 @@ private struct GeneralSettings: View { } } + Section("Quick Paste") { + LabeledContent("Paste items 1–9") { + HStack(spacing: 6) { + modifierPicker(selection: $settings.quickPasteModifier) + Text("+ 1…9").foregroundStyle(.secondary) + } + } + LabeledContent("Paste as plain text") { + modifierPicker(selection: $settings.plainTextModifier) + } + Text("Hold the plain-text modifier while using Quick Paste to remove formatting. For example, ⌘⇧1 pastes the first item as plain text with the default shortcuts.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) @@ -111,6 +126,17 @@ private struct GeneralSettings: View { } } #endif + + private func modifierPicker(selection: Binding) -> some View { + Picker("", selection: selection) { + ForEach(ShortcutModifier.allCases) { modifier in + Text("\(modifier.symbol) \(modifier.title)").tag(modifier.carbonValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(minWidth: 118) + } } private struct AboutView: View { diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..9857027 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -7,6 +7,7 @@ struct ClipCardView: View { @State private var hovering = false private var store: ClipboardStore { ClipboardStore.shared } + private var settings: Settings { Settings.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } var body: some View { @@ -143,8 +144,8 @@ struct ClipCardView: View { Spacer(minLength: 4) if index < 9 { HStack(spacing: 3) { - Image(systemName: "line.3.horizontal") - .font(.system(size: 9, weight: .semibold)) + Text(settings.quickPasteModifierDisplay) + .font(.system(size: 11, weight: .semibold)) Text("\(index + 1)") .font(.system(size: 11, weight: .semibold)) } From 545a241919d2f3301a9101a4dfe25a9cc7ad39cb Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:53:52 -0700 Subject: [PATCH 12/44] Add count and time history retention --- Sources/Pesty/AppController.swift | 1 + Sources/Pesty/Settings/Settings.swift | 132 +++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 50 +++++++- Sources/Pesty/Store/ClipboardStore.swift | 33 ++++-- 4 files changed, 201 insertions(+), 15 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..cf63e17 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -136,6 +136,7 @@ final class AppController: NSObject, NSApplicationDelegate { } store.searchText = "" store.source = .history + store.applyHistoryPolicy() store.selectFirst() if barController == nil { diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..a3c9597 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,112 @@ import AppKit import Carbon.HIToolbox import Observation +enum HistoryRetention: Int, CaseIterable, Identifiable { + case day + case week + case month + case year + case forever + case twoWeeks + case threeWeeks + case twoMonths + case threeMonths + case sixMonths + + // Preserve the original raw values so a saved selection remains valid as + // additional intervals are introduced. + static let allCases: [HistoryRetention] = [ + .day, .week, .twoWeeks, .threeWeeks, .month, + .twoMonths, .threeMonths, .sixMonths, .year, .forever + ] + + var id: Int { rawValue } + + var title: String { + switch self { + case .day: "1 Day" + case .week: "1 Week" + case .twoWeeks: "2 Weeks" + case .threeWeeks: "3 Weeks" + case .month: "1 Month" + case .twoMonths: "2 Months" + case .threeMonths: "3 Months" + case .sixMonths: "6 Months" + case .year: "1 Year" + case .forever: "Forever" + } + } + + var description: String { + switch self { + case .day: "Clips are kept for 24 hours." + case .week: "Clips are kept for 7 days." + case .twoWeeks: "Clips are kept for 2 weeks." + case .threeWeeks: "Clips are kept for 3 weeks." + case .month: "Clips are kept for 1 month." + case .twoMonths: "Clips are kept for 2 months." + case .threeMonths: "Clips are kept for 3 months." + case .sixMonths: "Clips are kept for 6 months." + case .year: "Clips are kept for 1 year." + case .forever: "Clips are kept until you erase them." + } + } + + var cutoffDate: Date? { + let calendar = Calendar.current + switch self { + case .day: return calendar.date(byAdding: .day, value: -1, to: .now) + case .week: return calendar.date(byAdding: .day, value: -7, to: .now) + case .twoWeeks: return calendar.date(byAdding: .day, value: -14, to: .now) + case .threeWeeks: return calendar.date(byAdding: .day, value: -21, to: .now) + case .month: return calendar.date(byAdding: .month, value: -1, to: .now) + case .twoMonths: return calendar.date(byAdding: .month, value: -2, to: .now) + case .threeMonths: return calendar.date(byAdding: .month, value: -3, to: .now) + case .sixMonths: return calendar.date(byAdding: .month, value: -6, to: .now) + case .year: return calendar.date(byAdding: .year, value: -1, to: .now) + case .forever: return nil + } + } + + var sliderIndex: Double { + Double(Self.allCases.firstIndex(of: self) ?? 0) + } + + var shortSliderTitle: String { + switch self { + case .day: "1d" + case .week: "1w" + case .twoWeeks: "2w" + case .threeWeeks: "3w" + case .month: "1m" + case .twoMonths: "2m" + case .threeMonths: "3m" + case .sixMonths: "6m" + case .year: "1y" + case .forever: "∞" + } + } + + init(sliderIndex: Double) { + let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded()))) + self = Self.allCases[index] + } +} + +enum HistoryRetentionMode: Int, CaseIterable, Identifiable { + case itemCount + case timePeriod + + var id: Int { rawValue } + + var title: String { + switch self { + case .itemCount: "Number" + case .timePeriod: "Time" + } + } +} + @Observable @MainActor final class Settings { @@ -12,6 +118,8 @@ final class Settings { enum Keys { static let historyLimit = "historyLimit" + static let historyRetentionMode = "historyRetentionMode" + static let historyRetention = "historyRetention" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let launchAtLogin = "launchAtLogin" @@ -28,7 +136,25 @@ final class Settings { guard isLoaded else { return } if historyLimit < 20 { historyLimit = 20; return } d.set(historyLimit, forKey: Keys.historyLimit) - ClipboardStore.shared.applyHistoryLimit() + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetentionMode: HistoryRetentionMode { + didSet { + guard isLoaded else { return } + d.set(historyRetentionMode.rawValue, forKey: Keys.historyRetentionMode) + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetention: HistoryRetention { + didSet { + guard isLoaded else { return } + d.set(historyRetention.rawValue, forKey: Keys.historyRetention) + if historyRetentionMode == .timePeriod { + ClipboardStore.shared.applyHistoryPolicy() + } } } @@ -79,6 +205,8 @@ final class Settings { private init() { d.register(defaults: [ Keys.historyLimit: 500, + Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, + Keys.historyRetention: HistoryRetention.month.rawValue, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.launchAtLogin: false, @@ -90,6 +218,8 @@ final class Settings { Keys.iCloudSync: false ]) historyLimit = d.integer(forKey: Keys.historyLimit) + historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount + historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..982752a 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -26,8 +26,47 @@ private struct GeneralSettings: View { Form { Section("Activation") { LabeledContent("Show Pesty") { HotkeyRecorderView() } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + } + + Section("History") { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + Spacer() + Text(settings.historyRetention.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: retentionSliderValue, + in: 0...Double(HistoryRetention.allCases.count - 1), + step: 1) + HStack(spacing: 0) { + ForEach(HistoryRetention.allCases) { retention in + Text(retention.shortSliderTitle) + .font(.caption2.weight(retention == settings.historyRetention ? .bold : .medium)) + .foregroundStyle(retention == settings.historyRetention ? Color.accentColor : .secondary) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) } } @@ -111,6 +150,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..4b9839f 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -18,11 +18,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 +52,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -91,25 +87,37 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() return } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = item.id } scheduleSave() } - func applyHistoryLimit() { trimHistory(); scheduleSave() } + func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } - private func trimHistory() { - guard history.count > historyLimit else { return } - let removed = Array(history[historyLimit...]) - history.removeLast(history.count - historyLimit) + @discardableResult + private func applyHistoryPolicyNow() -> Bool { + let removed: [ClipItem] + switch Settings.shared.historyRetentionMode { + case .itemCount: + guard history.count > Settings.shared.historyLimit else { return false } + removed = Array(history[Settings.shared.historyLimit...]) + history.removeLast(history.count - Settings.shared.historyLimit) + case .timePeriod: + guard let cutoff = Settings.shared.historyRetention.cutoffDate else { return false } + removed = history.filter { $0.createdAt < cutoff } + guard !removed.isEmpty else { return false } + history.removeAll { $0.createdAt < cutoff } + } for item in removed { deleteImageFile(item) } + return true } func delete(_ item: ClipItem) { @@ -298,7 +306,8 @@ final class ClipboardStore { var seen = Set() var merged: [ClipItem] = [] for it in combined where seen.insert(contentKey(it)).inserted { merged.append(it) } - history = Array(merged.prefix(historyLimit)) + history = merged + applyHistoryPolicyNow() var byID: [UUID: Pinboard] = Dictionary(uniqueKeysWithValues: pinboards.map { ($0.id, $0) }) for b in snap.pinboards { From 3a2028aa7bcd8fb51dfb78d2889976cd26aaa8c6 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:55:43 -0700 Subject: [PATCH 13/44] Add click-outside hide preference --- Sources/Pesty/Settings/Settings.swift | 7 +++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/BarWindowController.swift | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..33306b9 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -15,6 +15,7 @@ final class Settings { static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let launchAtLogin = "launchAtLogin" + static let hideOnClickOutside = "hideOnClickOutside" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" @@ -47,6 +48,10 @@ final class Settings { d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } } + var hideOnClickOutside: Bool { + didSet { guard isLoaded else { return }; d.set(hideOnClickOutside, forKey: Keys.hideOnClickOutside) } + } + var pasteDirectly: Bool { didSet { guard isLoaded else { return }; d.set(pasteDirectly, forKey: Keys.pasteDirectly) } } @@ -82,6 +87,7 @@ final class Settings { Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.launchAtLogin: false, + Keys.hideOnClickOutside: true, Keys.pasteDirectly: true, Keys.playSound: false, Keys.ignoreConcealed: true, @@ -93,6 +99,7 @@ final class Settings { hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) + hideOnClickOutside = d.bool(forKey: Keys.hideOnClickOutside) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..af8a85c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -37,6 +37,7 @@ private struct GeneralSettings: View { #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) + Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) Toggle("Launch at login", isOn: $settings.launchAtLogin) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..0553b90 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -69,7 +69,9 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { } func windowDidResignKey(_ notification: Notification) { - guard !isPresenting, !AppController.shared.suppressAutoHide else { return } + guard Settings.shared.hideOnClickOutside, + !isPresenting, + !AppController.shared.suppressAutoHide else { return } AppController.shared.hideBar() } } From f60114fa2d9e1529467c1a98f329a8a81bf404d2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:55:57 -0700 Subject: [PATCH 14/44] Add menu bar visibility preference --- Sources/Pesty/AppController.swift | 12 +++++++++++- Sources/Pesty/Settings/Settings.swift | 11 +++++++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..75afaa9 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -31,7 +31,7 @@ final class AppController: NSObject, NSApplicationDelegate { HotKeyCenter.shared.onTrigger = { [weak self] in self?.toggleBar() } HotKeyCenter.shared.start() - setupStatusItem() + setMenuBarIconVisible(Settings.shared.showMenuBarIcon) if Settings.shared.launchAtLogin { LaunchAtLogin.set(enabled: true) } @@ -63,6 +63,7 @@ final class AppController: NSObject, NSApplicationDelegate { } private func setupStatusItem() { + guard statusItem == nil else { return } let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = item.button { button.image = NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: "Pesty") @@ -82,6 +83,15 @@ final class AppController: NSObject, NSApplicationDelegate { statusItem = item } + func setMenuBarIconVisible(_ visible: Bool) { + if visible { + setupStatusItem() + } else if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + @objc private func menuOpen() { showBar() } @objc private func menuSettings() { showSettings() } @objc private func menuClear() { store.clearHistory() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..73e0c0d 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -19,6 +19,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -68,6 +69,14 @@ final class Settings { } } + var showMenuBarIcon: Bool { + didSet { + guard isLoaded else { return } + d.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon) + AppController.shared.setMenuBarIconVisible(showMenuBarIcon) + } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -86,6 +95,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -97,6 +107,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..a3c424c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -38,6 +38,7 @@ private struct GeneralSettings: View { Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) Toggle("Launch at login", isOn: $settings.launchAtLogin) + Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) From 36969a36c0de0219d5ad6ae15a2e2fb15facbf5d Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:56:28 -0700 Subject: [PATCH 15/44] Hide Pesty when switching apps --- Sources/Pesty/AppController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..af86a7a 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -55,6 +55,12 @@ final class AppController: NSObject, NSApplicationDelegate { guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } if app.bundleIdentifier != Bundle.main.bundleIdentifier { lastActiveApp = app + // Command-Tab and app switching do not reliably make our borderless + // panel resign key. Treat activation of another app as an explicit + // dismissal so the bar never stays above the newly active app. + if barController?.window?.isVisible == true { + hideBar() + } } } From 9efdab2ff340c357509d82b8f0c824a100c5e54f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:59:14 -0700 Subject: [PATCH 16/44] Add a Quit action to Settings --- Sources/Pesty/Settings/SettingsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..eebd36d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -130,6 +130,10 @@ private struct AboutView: View { Link("Report an Issue", destination: URL(string: "https://github.com/momenbasel/pesty/issues")!) } .padding(.top, 4) + Button("Quit Pesty", role: .destructive) { + NSApp.terminate(nil) + } + .padding(.top, 8) Spacer() Text("MIT Licensed · Made with SwiftUI") .font(.caption).foregroundStyle(.tertiary) From b26d6575f2e7b133c7d87753e6f175b02cfc6f61 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:59:44 -0700 Subject: [PATCH 17/44] Use native switches in Settings --- Sources/Pesty/Settings/SettingsView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..6986e98 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -34,10 +34,14 @@ private struct GeneralSettings: View { Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) + .toggleStyle(.switch) #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) + .toggleStyle(.switch) Toggle("Play sound on paste", isOn: $settings.playSound) + .toggleStyle(.switch) Toggle("Launch at login", isOn: $settings.launchAtLogin) + .toggleStyle(.switch) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) @@ -52,6 +56,7 @@ private struct GeneralSettings: View { Toggle("Sync clipboard via iCloud Drive", isOn: Binding( get: { settings.iCloudSync }, set: { _ in AppController.shared.toggleICloudSync() })) + .toggleStyle(.switch) Text(ClipboardStore.shared.iCloudAvailable ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." : "Sign in to iCloud and enable iCloud Drive to use sync.") From 59e94d4b162217a3432334e76557332803ee8351 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:00:58 -0700 Subject: [PATCH 18/44] Exclude selected apps from clipboard history --- Sources/Pesty/Monitor/ClipboardMonitor.swift | 1 + Sources/Pesty/Settings/Settings.swift | 26 +++++++ Sources/Pesty/Settings/SettingsView.swift | 82 ++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..15b810c 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -50,6 +50,7 @@ final class ClipboardMonitor { } let bundleID = src?.bundleIdentifier let appName = src?.localizedName + guard !Settings.shared.isIgnoringSourceApp(bundleID) else { return nil } func decorate(_ item: inout ClipItem) { item.sourceBundleID = bundleID diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..bd99eda 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -18,6 +18,7 @@ final class Settings { static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" + static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -59,6 +60,13 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(ignoreConcealed, forKey: Keys.ignoreConcealed) } } + /// Applications whose copied content should never be recorded in history. + /// Store bundle identifiers rather than paths so the choice continues to work + /// when an app is updated or moved. + private(set) var ignoredSourceAppBundleIDs: [String] { + didSet { guard isLoaded else { return }; d.set(ignoredSourceAppBundleIDs, forKey: Keys.ignoredSourceAppBundleIDs) } + } + var barHeight: Double { didSet { guard isLoaded else { return } @@ -85,6 +93,7 @@ final class Settings { Keys.pasteDirectly: true, Keys.playSound: false, Keys.ignoreConcealed: true, + Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, Keys.onboarded: false, Keys.iCloudSync: false @@ -96,6 +105,8 @@ final class Settings { pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) + ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) + .filter { !$0.isEmpty } barHeight = d.double(forKey: Keys.barHeight) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) @@ -105,4 +116,19 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + func isIgnoringSourceApp(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return ignoredSourceAppBundleIDs.contains(bundleID) + } + + func addIgnoredSourceApp(_ bundleID: String) { + guard !bundleID.isEmpty, !ignoredSourceAppBundleIDs.contains(bundleID) else { return } + ignoredSourceAppBundleIDs.append(bundleID) + ignoredSourceAppBundleIDs.sort() + } + + func removeIgnoredSourceApp(_ bundleID: String) { + ignoredSourceAppBundleIDs.removeAll { $0 == bundleID } + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..ab000df 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -6,6 +6,8 @@ struct SettingsView: View { TabView { GeneralSettings() .tabItem { Label("General", systemImage: "gearshape") } + PrivacySettings() + .tabItem { Label("Privacy", systemImage: "hand.raised") } AboutView() .tabItem { Label("About", systemImage: "info.circle") } } @@ -13,6 +15,86 @@ struct SettingsView: View { } } +private struct PrivacySettings: View { + @Bindable private var settings = Settings.shared + + var body: some View { + Form { + Section("Excluded Apps") { + Text("Pesty will not save anything copied while one of these apps is active. This is useful for password managers such as 1Password.") + .font(.caption) + .foregroundStyle(.secondary) + + if settings.ignoredSourceAppBundleIDs.isEmpty { + ContentUnavailableView("No apps excluded", + systemImage: "hand.raised", + description: Text("Add an app to keep its copied content out of Pesty.")) + .padding(.vertical, 12) + } else { + ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in + ignoredAppRow(bundleID) + } + } + + Button { chooseApps() } label: { + Label("Add App…", systemImage: "plus") + } + } + } + .formStyle(.grouped) + } + + private func ignoredAppRow(_ bundleID: String) -> some View { + HStack(spacing: 10) { + Image(nsImage: AppIconProvider.icon(forBundleID: bundleID)) + .resizable() + .interpolation(.high) + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text(applicationName(for: bundleID)) + .font(.system(size: 13, weight: .medium)) + Text(bundleID) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + Button { settings.removeIgnoredSourceApp(bundleID) } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Allow clips from \(applicationName(for: bundleID))") + } + .padding(.vertical, 4) + } + + private func chooseApps() { + let panel = NSOpenPanel() + panel.title = "Exclude Apps from Pesty" + panel.message = "Pesty will ignore copied content from the apps you choose." + panel.prompt = "Add Apps" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.applicationBundle] + guard panel.runModal() == .OK else { return } + for url in panel.urls { + guard let bundleID = Bundle(url: url)?.bundleIdentifier, + bundleID != Bundle.main.bundleIdentifier else { continue } + settings.addIgnoredSourceApp(bundleID) + } + } + + private func applicationName(for bundleID: String) -> String { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID), + let bundle = Bundle(url: url) else { return bundleID } + return (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? bundleID + } +} + private struct GeneralSettings: View { @Bindable private var settings = Settings.shared #if !MAS From 0c40adce573b9ffb7b4ea84bf3b7b773f832bb25 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:17:04 -0700 Subject: [PATCH 19/44] Add in-bar Paste Stack deck workflow --- Sources/Pesty/AppController.swift | 102 ++++++++++-- Sources/Pesty/Store/ClipboardStore.swift | 6 + Sources/Pesty/Store/PasteSequence.swift | 88 +++++++++- Sources/Pesty/UI/BarView.swift | 22 ++- Sources/Pesty/UI/PasteStackDeckCard.swift | 115 +++++++++++++ Sources/Pesty/UI/PasteStackView.swift | 191 +++++++++++++++++++++- Sources/Pesty/UI/PinboardTabs.swift | 18 ++ 7 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 Sources/Pesty/UI/PasteStackDeckCard.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index a4f2a26..55d87cd 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -133,14 +133,14 @@ final class AppController: NSObject, NSApplicationDelegate { } } - func showBar() { + func showBar(source requestedSource: BarSource? = nil) { let front = NSWorkspace.shared.frontmostApplication if front?.bundleIdentifier != Bundle.main.bundleIdentifier { previousApp = front } store.searchText = "" - store.source = .history - store.selectFirst() + store.source = requestedSource ?? .history + if store.source != .pasteStack { store.selectFirst() } if barController == nil { barController = BarWindowController() @@ -190,32 +190,87 @@ final class AppController: NSObject, NSApplicationDelegate { pasteStackController?.show() } + func hidePasteStack() { + pasteStackController?.hide() + } + + func showPasteStackTab() { + store.searchText = "" + store.source = .pasteStack + pasteSequence.selectFirst() + pasteStackController?.hide() + if barController?.window?.isVisible != true { + showBar(source: .pasteStack) + } + } + func cancelPasteSequence() { pasteSequence.cancel() pasteStackController?.hide() pasteStackTargetApp = nil } + func newPasteStack() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.newStack() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func pausePasteSequence() { + pasteSequence.pause() + } + + func clearPasteStack() { + cancelPasteSequence() + } + func capturePasteStackItem(_ item: ClipItem) { _ = pasteSequence.addIfNeeded(item) } - func pasteNextInSequence() { - guard pasteSequence.hasEntries else { return } + func removePasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.remove(entry) + } + func reAddPasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.reAdd(entry) + } + + func resetPasteStackProgress() { + pasteSequence.resetProgress() + } + + func pasteNextInSequence() { #if !MAS guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } #endif guard let entry = pasteSequence.next() else { return } + performPasteStackEntry(entry) + } + + func pasteStackEntry(_ entry: PasteStackEntry) { + guard let entry = pasteSequence.next(entryID: entry.id) else { return } + performPasteStackEntry(entry) + } + + func pasteSelectedStackEntry() { + guard let entry = pasteSequence.selectedEntry else { return } + pasteStackEntry(entry) + } + + private func performPasteStackEntry(_ entry: PasteStackEntry) { + hideBar() PasteService.paste(entry.item, into: pasteStackTargetApp ?? previousApp, monitor: monitor, imageOverride: entry.imagePreview) - if !pasteSequence.hasEntries { - pasteStackController?.hide() - pasteStackTargetApp = nil - } } func showSettings() { @@ -255,7 +310,11 @@ final class AppController: NSObject, NSApplicationDelegate { let ctrl = flags.contains(.control) let opt = flags.contains(.option) - if cmd, let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n) { + if store.source != .pasteStack, + cmd, + let chars = event.charactersIgnoringModifiers, + let n = Int(chars), + (1...9).contains(n) { let items = store.visibleItems if n <= items.count { pasteItem(items[n - 1]) } return nil @@ -267,25 +326,46 @@ final class AppController: NSObject, NSApplicationDelegate { else { hideBar() } return nil case kVK_Return, kVK_ANSI_KeypadEnter: + if store.source == .pasteStack { + pasteSelectedStackEntry() + return nil + } pasteSelected(); return nil case kVK_LeftArrow, kVK_UpArrow: + if store.source == .pasteStack { + pasteSequence.moveSelection(by: -1) + return nil + } store.moveSelection(by: -1); return nil case kVK_RightArrow, kVK_DownArrow: + if store.source == .pasteStack { + pasteSequence.moveSelection(by: 1) + return nil + } store.moveSelection(by: 1); return nil case kVK_Delete: + if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + return nil + } if cmd, let sel = store.selectedItem { store.delete(sel); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst(); return nil } return nil case kVK_ForwardDelete: + if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + return nil + } if let sel = store.selectedItem { store.delete(sel) } return nil default: break } - if !cmd && !ctrl && !opt, + if store.source != .pasteStack, + !cmd && !ctrl && !opt, let chars = event.characters, chars.count == 1, let scalar = chars.unicodeScalars.first, scalar.value >= 32, scalar.value != 127 { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index aca9d3a..715366e 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -3,6 +3,7 @@ import Observation enum BarSource: Equatable { case history + case pasteStack case pinboard(UUID) } @@ -72,6 +73,11 @@ final class ClipboardStore { switch source { case .history: base = history + case .pasteStack: + // Paste Stack entries have their own identity and selection state. + // PasteStackContentView renders them directly instead of folding + // them into the clipboard history strip. + base = [] case .pinboard(let id): base = pinboards.first(where: { $0.id == id })?.items ?? [] } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index b9ab73b..2fe70af 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -7,6 +7,9 @@ struct PasteStackEntry: Identifiable { /// Holds an in-memory image while a Stack is active, even if the image is /// later pruned from clipboard history. let imagePreview: NSImage? + /// Pasted clips remain in the stack so its deck can show progress and be + /// reset without asking the user to collect the same clips again. + var isPasted = false init(item: ClipItem, imagePreview: NSImage? = nil) { self.item = item @@ -21,15 +24,36 @@ final class PasteSequence { private(set) var entries: [PasteStackEntry] = [] private(set) var isCollecting = false + private(set) var selectedEntryID: UUID? var hasEntries: Bool { !entries.isEmpty } - var pendingCount: Int { entries.count } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + /// Keep pending clips at the front and previously pasted clips at the end + /// of the deck. The next clip is therefore always visually first. + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) + } + var selectedEntry: PasteStackEntry? { + guard let selectedEntryID else { return nil } + return entries.first(where: { $0.id == selectedEntryID }) + } private init() {} func begin() { + isCollecting = true + if selectedEntryID == nil { selectFirst() } + } + + func pause() { + isCollecting = false + } + + func newStack() { entries.removeAll() isCollecting = true + selectedEntryID = nil } @discardableResult @@ -38,20 +62,78 @@ final class PasteSequence { !entries.contains(where: { $0.item.id == item.id }) else { return false } let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) + if selectedEntryID == nil { selectFirst() } return true } + func selectFirst() { + selectedEntryID = displayEntries.first?.id + } + + func select(_ entry: PasteStackEntry) { + selectedEntryID = entry.id + } + + func moveSelection(by delta: Int) { + let displayed = displayEntries + guard !displayed.isEmpty else { + selectedEntryID = nil + return + } + guard let selectedEntryID, + let index = displayed.firstIndex(where: { $0.id == selectedEntryID }) else { + self.selectedEntryID = displayed.first?.id + return + } + let next = max(0, min(displayed.count - 1, index + delta)) + self.selectedEntryID = displayed[next].id + } + func next() -> PasteStackEntry? { - guard !entries.isEmpty else { + guard let index = entries.firstIndex(where: { !$0.isPasted }) else { isCollecting = false return nil } + return takeEntry(at: index) + } + + func next(entryID: UUID) -> PasteStackEntry? { + guard let index = entries.firstIndex(where: { $0.id == entryID && !$0.isPasted }) else { + return nil + } + return takeEntry(at: index) + } + + func reAdd(_ entry: PasteStackEntry) { + entries.append(PasteStackEntry(item: entry.item, imagePreview: entry.imagePreview)) + selectFirst() + } + + func remove(_ entry: PasteStackEntry) { + entries.removeAll { $0.id == entry.id } + if selectedEntryID == entry.id { selectFirst() } + } + + func resetProgress() { + for index in entries.indices { + entries[index].isPasted = false + } isCollecting = false - return entries.removeFirst() + selectFirst() } func cancel() { entries.removeAll() isCollecting = false + selectedEntryID = nil + } + + private func takeEntry(at index: Int) -> PasteStackEntry { + var entry = entries.remove(at: index) + entry.isPasted = true + entries.append(entry) + isCollecting = false + selectFirst() + return entry } } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index b7f77f5..060881f 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,6 +3,11 @@ import SwiftUI struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + private var sequence: PasteSequence { AppController.shared.pasteSequence } + + private var showsStackDeck: Bool { + store.source == .history && store.searchText.isEmpty && sequence.hasEntries + } var body: some View { ZStack { @@ -12,7 +17,11 @@ struct BarView: View { .overlay(alignment: .top) { VStack(spacing: 0) { topBar - strip + if store.source == .pasteStack { + PasteStackContentView() + } else { + strip + } } } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) @@ -22,7 +31,7 @@ struct BarView: View { private var topBar: some View { HStack(spacing: 14) { syncButton - searchIndicator + if store.source != .pasteStack { searchIndicator } PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) @@ -104,6 +113,11 @@ struct BarView: View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: Theme.cardSpacing) { + if showsStackDeck { + PasteStackDeckCard() + .id("pesty.paste-stack.deck") + } + ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in ClipCardView(item: item, index: index, @@ -125,7 +139,9 @@ struct BarView: View { proxy.scrollTo(id, anchor: .center) } } - .overlay { if store.visibleItems.isEmpty { emptyState } } + .overlay { + if store.visibleItems.isEmpty && !showsStackDeck { emptyState } + } } .frame(maxHeight: .infinity) } diff --git a/Sources/Pesty/UI/PasteStackDeckCard.swift b/Sources/Pesty/UI/PasteStackDeckCard.swift new file mode 100644 index 0000000..9398175 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// A compact representation of the active Paste Stack in the Clipboard strip. +/// The card opens the focused stack tab rather than behaving like a history +/// clip, which keeps collection and sequential paste actions unambiguous. +struct PasteStackDeckCard: View { + private var stack: PasteSequence { AppController.shared.pasteSequence } + + private var nextEntry: PasteStackEntry? { + stack.displayEntries.first(where: { !$0.isPasted }) + } + + var body: some View { + Button { AppController.shared.showPasteStackTab() } label: { + ZStack(alignment: .topLeading) { + if stack.pendingCount > 2 { deckLayer(offset: 12, opacity: 0.20) } + if stack.pendingCount > 1 { deckLayer(offset: 6, opacity: 0.34) } + frontCard + } + .frame(width: Theme.cardWidth + 12, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + } + .buttonStyle(.plain) + .help("Open Paste Stack") + } + + private func deckLayer(offset: CGFloat, opacity: Double) -> some View { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .fill(Theme.selection.opacity(opacity)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(Theme.selection.opacity(0.25)) + } + .offset(x: offset, y: offset) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + + private var frontCard: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "rectangle.stack.fill") + .font(.system(size: 16, weight: .semibold)) + VStack(alignment: .leading, spacing: 1) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(stack.isCollecting ? "Collecting clips" : "Ready to paste") + .font(.system(size: 11)) + .foregroundStyle(Theme.headerSubText) + } + Spacer(minLength: 4) + Text("\(stack.pendingCount)") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.18), in: Capsule()) + } + .foregroundStyle(Theme.headerText) + .padding(.horizontal, 13) + .frame(height: Theme.headerHeight) + .background(Theme.selection) + + VStack(spacing: 10) { + if let entry = nextEntry { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 50, height: 50) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + Text(entry.item.displayTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(3) + .multilineTextAlignment(.center) + Text("Next clip") + .font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary) + } else { + Image(systemName: "checkmark.circle") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Stack complete") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.cardBody) + + HStack { + Text("\(stack.pendingCount) queued") + Spacer() + Text("Open stack") + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Theme.cardBody) + } + .frame(width: Theme.cardWidth) + .frame(maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(Theme.selection.opacity(0.7), lineWidth: 2) + } + .shadow(color: .black.opacity(0.16), radius: 5, y: 2) + } +} diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 65ab4fa..2ca0cc7 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -32,13 +32,33 @@ struct PasteStackView: View { } Spacer() Button { - AppController.shared.cancelPasteSequence() + AppController.shared.showPasteStackTab() + } label: { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + } + .buttonStyle(.plain) + .help("Open Paste Stack in Pesty") + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Image(systemName: stack.isCollecting ? "pause.circle.fill" : "play.circle.fill") + .foregroundStyle(stack.isCollecting ? .orange : Theme.selection) + } + .buttonStyle(.plain) + .help(stack.isCollecting ? "Pause collecting clips" : "Resume collecting clips") + Button { + AppController.shared.hidePasteStack() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) - .help("Cancel Paste Stack") + .help("Hide Paste Stack") } .padding(.horizontal, 15) .padding(.vertical, 12) @@ -63,8 +83,11 @@ struct PasteStackView: View { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 7) { - ForEach(Array(stack.entries.enumerated()), id: \.element.id) { index, entry in - PasteStackEntryRow(entry: entry, index: index + 1) + ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: false) } } .padding(12) @@ -81,12 +104,19 @@ struct PasteStackView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .disabled(!stack.hasEntries) + .disabled(stack.pendingCount == 0) Text(Settings.shared.sequenceHotkeyDisplay) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) Spacer() + if stack.hasEntries { + Button { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Clear Paste Stack") + } } .padding(.horizontal, 15) .frame(height: 52) @@ -96,13 +126,18 @@ struct PasteStackView: View { if stack.isCollecting { return stack.hasEntries ? "\(stack.pendingCount) collected - copy more" : "Copy clips in another app" } - return stack.hasEntries ? "\(stack.pendingCount) ready to paste" : "Stack complete" + if stack.pendingCount > 0 { return "\(stack.pendingCount) ready to paste" } + return stack.hasEntries ? "Stack complete" : "Collection paused" } } private struct PasteStackEntryRow: View { let entry: PasteStackEntry let index: Int + let selected: Bool + let showsPasteAction: Bool + + private var stack: PasteSequence { AppController.shared.pasteSequence } var body: some View { HStack(spacing: 10) { @@ -120,11 +155,41 @@ private struct PasteStackEntryRow: View { .foregroundStyle(.secondary) } Spacer(minLength: 0) - sourceAppIcon + VStack(alignment: .trailing, spacing: 6) { + if showsPasteAction { + if entry.isPasted { + Button("Re-add") { AppController.shared.reAddPasteStackEntry(entry) } + .buttonStyle(.bordered) + .controlSize(.mini) + } else { + Button("Paste") { AppController.shared.pasteStackEntry(entry) } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + } + } + HStack(spacing: 7) { + sourceAppIcon + Button { AppController.shared.removePasteStackEntry(entry) } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Remove from Paste Stack") + } + } } .padding(.horizontal, 10) .padding(.vertical, 9) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(rowBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(selected ? Theme.selection : .clear, lineWidth: selected ? 2 : 0) + } + .opacity(entry.isPasted ? 0.52 : 1) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .onTapGesture { stack.select(entry) } + .help(entry.isPasted ? "Pasted clip" : "Select this stack clip") } @ViewBuilder @@ -166,4 +231,114 @@ private struct PasteStackEntryRow: View { .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) .help(entry.item.sourceAppName ?? "Source app") } + + private var rowBackground: Color { + selected ? Theme.selection.opacity(0.13) : Color.white.opacity(0.10) + } +} + +/// The full Paste Stack tab in Pesty. It exposes the active deck without +/// turning its entries into ordinary clipboard-history cards. +struct PasteStackContentView: View { + private var stack: PasteSequence { AppController.shared.pasteSequence } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + entries + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 9) { + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 16, weight: .bold)) + Text(summary) + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + Spacer() + + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Label(stack.isCollecting ? "Pause" : "Collect", + systemImage: stack.isCollecting ? "pause.fill" : "play.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(stack.isCollecting ? .orange : Theme.selection) + + if stack.pendingCount > 0 { + Button("Paste Next") { AppController.shared.pasteNextInSequence() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + if stack.pastedCount > 0 { + Button("Reset") { AppController.shared.resetPasteStackProgress() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Make every Paste Stack clip ready again") + } + + Button("New Stack") { AppController.shared.newPasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + + if stack.hasEntries { + Button(role: .destructive) { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Clear Paste Stack") + } + } + .padding(.horizontal, 24) + .padding(.vertical, 13) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Start collecting clips") + .font(.system(size: 15, weight: .semibold)) + Text("Choose Collect, then copy text, images, or files in any app.") + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 8) { + ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: true) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } + } + + private var summary: String { + if stack.isCollecting { return "Collecting clips from other apps" } + if stack.pendingCount > 0 { return "\(stack.pendingCount) clips ready to paste" } + return stack.hasEntries ? "All clips pasted" : "Collection paused" + } } diff --git a/Sources/Pesty/UI/PinboardTabs.swift b/Sources/Pesty/UI/PinboardTabs.swift index 01aa291..379e9da 100644 --- a/Sources/Pesty/UI/PinboardTabs.swift +++ b/Sources/Pesty/UI/PinboardTabs.swift @@ -2,6 +2,7 @@ import SwiftUI struct PinboardTabs: View { @Bindable private var store = ClipboardStore.shared + private var stack: PasteSequence { AppController.shared.pasteSequence } var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -13,6 +14,14 @@ struct PinboardTabs: View { store.source = .history; store.selectFirst() } + pill(title: "Paste Stack", + dot: nil, + icon: "rectangle.stack.fill", + badge: stack.pendingCount, + selected: store.source == .pasteStack) { + AppController.shared.showPasteStackTab() + } + ForEach(store.pinboards) { board in pill(title: board.name, dot: board.color, @@ -41,6 +50,7 @@ struct PinboardTabs: View { } private func pill(title: String, dot: Color?, icon: String? = nil, + badge: Int? = nil, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { @@ -54,6 +64,14 @@ struct PinboardTabs: View { Text(title) .font(.system(size: 12.5, weight: .medium)) .lineLimit(1) + if let badge, badge > 0 { + Text("\(badge)") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(selected ? .white : Theme.selection) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(selected ? Theme.selection : Theme.selection.opacity(0.14), in: Capsule()) + } } .foregroundStyle(selected ? Theme.textPrimary : Theme.textSecondary) .padding(.horizontal, 12) From e467cd3f01d949c54ec5b4f622555dbf514e62f7 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:20:34 -0700 Subject: [PATCH 20/44] Paste stacks into active apps and save them --- Sources/Pesty/AppController.swift | 33 ++++++++++++++++++++++++++- Sources/Pesty/UI/PasteStackView.swift | 11 +++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 55d87cd..e0c551b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -246,6 +246,20 @@ final class AppController: NSObject, NSApplicationDelegate { pasteSequence.resetProgress() } + /// Saves the deck in its displayed paste order so a temporary Paste Stack + /// can become a durable Pinboard. + func savePasteStack() { + guard pasteSequence.hasEntries, + let name = TextPrompt.run(title: "Save Paste Stack", + message: "Save the current stack as a pinboard named:", + defaultValue: "Paste Stack") else { return } + + let board = store.addPinboard(name: name) + for entry in pasteSequence.displayEntries.reversed() { + store.saveToPinboard(entry.item, boardID: board.id) + } + } + func pasteNextInSequence() { #if !MAS guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } @@ -266,13 +280,30 @@ final class AppController: NSObject, NSApplicationDelegate { } private func performPasteStackEntry(_ entry: PasteStackEntry) { + let target = pasteTargetApp() hideBar() PasteService.paste(entry.item, - into: pasteStackTargetApp ?? previousApp, + into: target, monitor: monitor, imageOverride: entry.imagePreview) } + /// Resolve the destination when the user chooses to paste, rather than + /// holding the app that was active when the Stack was first opened. + private func pasteTargetApp() -> NSRunningApplication? { + if let frontmost = NSWorkspace.shared.frontmostApplication, + frontmost.bundleIdentifier != Bundle.main.bundleIdentifier { + previousApp = frontmost + return frontmost + } + if let lastActiveApp, + lastActiveApp.bundleIdentifier != Bundle.main.bundleIdentifier, + !lastActiveApp.isTerminated { + return lastActiveApp + } + return pasteStackTargetApp ?? previousApp + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 2ca0cc7..464becb 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -111,6 +111,10 @@ struct PasteStackView: View { .foregroundStyle(.secondary) Spacer() if stack.hasEntries { + Button("Save") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") Button { AppController.shared.clearPasteStack() } label: { Image(systemName: "trash") } @@ -289,6 +293,13 @@ struct PasteStackContentView: View { .help("Make every Paste Stack clip ready again") } + if stack.hasEntries { + Button("Save Stack…") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") + } + Button("New Stack") { AppController.shared.newPasteStack() } .buttonStyle(.bordered) .controlSize(.small) From 575cada8b74440722790381f509a4ad67d2e84a9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:53:52 -0700 Subject: [PATCH 21/44] Add count and time history retention --- Sources/Pesty/AppController.swift | 1 + Sources/Pesty/Settings/Settings.swift | 132 +++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 50 +++++++- Sources/Pesty/Store/ClipboardStore.swift | 33 ++++-- 4 files changed, 201 insertions(+), 15 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index e0c551b..f481daf 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -140,6 +140,7 @@ final class AppController: NSObject, NSApplicationDelegate { } store.searchText = "" store.source = requestedSource ?? .history + store.applyHistoryPolicy() if store.source != .pasteStack { store.selectFirst() } if barController == nil { diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 7b27845..c572989 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,112 @@ import AppKit import Carbon.HIToolbox import Observation +enum HistoryRetention: Int, CaseIterable, Identifiable { + case day + case week + case month + case year + case forever + case twoWeeks + case threeWeeks + case twoMonths + case threeMonths + case sixMonths + + // Preserve the original raw values so a saved selection remains valid as + // additional intervals are introduced. + static let allCases: [HistoryRetention] = [ + .day, .week, .twoWeeks, .threeWeeks, .month, + .twoMonths, .threeMonths, .sixMonths, .year, .forever + ] + + var id: Int { rawValue } + + var title: String { + switch self { + case .day: "1 Day" + case .week: "1 Week" + case .twoWeeks: "2 Weeks" + case .threeWeeks: "3 Weeks" + case .month: "1 Month" + case .twoMonths: "2 Months" + case .threeMonths: "3 Months" + case .sixMonths: "6 Months" + case .year: "1 Year" + case .forever: "Forever" + } + } + + var description: String { + switch self { + case .day: "Clips are kept for 24 hours." + case .week: "Clips are kept for 7 days." + case .twoWeeks: "Clips are kept for 2 weeks." + case .threeWeeks: "Clips are kept for 3 weeks." + case .month: "Clips are kept for 1 month." + case .twoMonths: "Clips are kept for 2 months." + case .threeMonths: "Clips are kept for 3 months." + case .sixMonths: "Clips are kept for 6 months." + case .year: "Clips are kept for 1 year." + case .forever: "Clips are kept until you erase them." + } + } + + var cutoffDate: Date? { + let calendar = Calendar.current + switch self { + case .day: return calendar.date(byAdding: .day, value: -1, to: .now) + case .week: return calendar.date(byAdding: .day, value: -7, to: .now) + case .twoWeeks: return calendar.date(byAdding: .day, value: -14, to: .now) + case .threeWeeks: return calendar.date(byAdding: .day, value: -21, to: .now) + case .month: return calendar.date(byAdding: .month, value: -1, to: .now) + case .twoMonths: return calendar.date(byAdding: .month, value: -2, to: .now) + case .threeMonths: return calendar.date(byAdding: .month, value: -3, to: .now) + case .sixMonths: return calendar.date(byAdding: .month, value: -6, to: .now) + case .year: return calendar.date(byAdding: .year, value: -1, to: .now) + case .forever: return nil + } + } + + var sliderIndex: Double { + Double(Self.allCases.firstIndex(of: self) ?? 0) + } + + var shortSliderTitle: String { + switch self { + case .day: "1d" + case .week: "1w" + case .twoWeeks: "2w" + case .threeWeeks: "3w" + case .month: "1m" + case .twoMonths: "2m" + case .threeMonths: "3m" + case .sixMonths: "6m" + case .year: "1y" + case .forever: "∞" + } + } + + init(sliderIndex: Double) { + let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded()))) + self = Self.allCases[index] + } +} + +enum HistoryRetentionMode: Int, CaseIterable, Identifiable { + case itemCount + case timePeriod + + var id: Int { rawValue } + + var title: String { + switch self { + case .itemCount: "Number" + case .timePeriod: "Time" + } + } +} + @Observable @MainActor final class Settings { @@ -12,6 +118,8 @@ final class Settings { enum Keys { static let historyLimit = "historyLimit" + static let historyRetentionMode = "historyRetentionMode" + static let historyRetention = "historyRetention" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" @@ -30,7 +138,25 @@ final class Settings { guard isLoaded else { return } if historyLimit < 20 { historyLimit = 20; return } d.set(historyLimit, forKey: Keys.historyLimit) - ClipboardStore.shared.applyHistoryLimit() + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetentionMode: HistoryRetentionMode { + didSet { + guard isLoaded else { return } + d.set(historyRetentionMode.rawValue, forKey: Keys.historyRetentionMode) + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetention: HistoryRetention { + didSet { + guard isLoaded else { return } + d.set(historyRetention.rawValue, forKey: Keys.historyRetention) + if historyRetentionMode == .timePeriod { + ClipboardStore.shared.applyHistoryPolicy() + } } } @@ -91,6 +217,8 @@ final class Settings { private init() { d.register(defaults: [ Keys.historyLimit: 500, + Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, + Keys.historyRetention: HistoryRetention.month.rawValue, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, @@ -104,6 +232,8 @@ final class Settings { Keys.iCloudSync: false ]) historyLimit = d.integer(forKey: Keys.historyLimit) + historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount + historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index fa0921e..d28fca9 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -29,8 +29,47 @@ private struct GeneralSettings: View { HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode, modifiers: $settings.hotkeyModifiers) } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + } + + Section("History") { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + Spacer() + Text(settings.historyRetention.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: retentionSliderValue, + in: 0...Double(HistoryRetention.allCases.count - 1), + step: 1) + HStack(spacing: 0) { + ForEach(HistoryRetention.allCases) { retention in + Text(retention.shortSliderTitle) + .font(.caption2.weight(retention == settings.historyRetention ? .bold : .medium)) + .foregroundStyle(retention == settings.historyRetention ? Color.accentColor : .secondary) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) } } @@ -124,6 +163,13 @@ private struct GeneralSettings: View { } } #endif + + private var retentionSliderValue: Binding { + Binding( + get: { settings.historyRetention.sliderIndex }, + set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } + ) + } } private struct AboutView: View { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 715366e..a40cdf6 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -19,11 +19,6 @@ final class ClipboardStore { var searchText: String = "" var selectedID: UUID? - var historyLimit: Int { - get { Settings.shared.historyLimit } - set { Settings.shared.historyLimit = newValue; trimHistory() } - } - private var storeURL: URL private var imagesDir: URL private var baseDir: URL @@ -58,6 +53,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -98,12 +94,13 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() return existing } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = item.id } @@ -111,13 +108,24 @@ final class ClipboardStore { return item } - func applyHistoryLimit() { trimHistory(); scheduleSave() } + func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } - private func trimHistory() { - guard history.count > historyLimit else { return } - let removed = Array(history[historyLimit...]) - history.removeLast(history.count - historyLimit) + @discardableResult + private func applyHistoryPolicyNow() -> Bool { + let removed: [ClipItem] + switch Settings.shared.historyRetentionMode { + case .itemCount: + guard history.count > Settings.shared.historyLimit else { return false } + removed = Array(history[Settings.shared.historyLimit...]) + history.removeLast(history.count - Settings.shared.historyLimit) + case .timePeriod: + guard let cutoff = Settings.shared.historyRetention.cutoffDate else { return false } + removed = history.filter { $0.createdAt < cutoff } + guard !removed.isEmpty else { return false } + history.removeAll { $0.createdAt < cutoff } + } for item in removed { deleteImageFile(item) } + return true } func delete(_ item: ClipItem) { @@ -306,7 +314,8 @@ final class ClipboardStore { var seen = Set() var merged: [ClipItem] = [] for it in combined where seen.insert(contentKey(it)).inserted { merged.append(it) } - history = Array(merged.prefix(historyLimit)) + history = merged + applyHistoryPolicyNow() var byID: [UUID: Pinboard] = Dictionary(uniqueKeysWithValues: pinboards.map { ($0.id, $0) }) for b in snap.pinboards { From f8826924717ae0d41972ed7c70a3405707d5b3d2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:24:48 -0700 Subject: [PATCH 22/44] Polish Settings layout and organization --- Sources/Pesty/AppController.swift | 2 +- Sources/Pesty/Settings/SettingsView.swift | 718 ++++++++++++++-------- 2 files changed, 478 insertions(+), 242 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 949487c..e95dd18 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -195,7 +195,7 @@ final class AppController: NSObject, NSApplicationDelegate { let win = NSWindow(contentViewController: host) win.title = "Pesty Settings" win.styleMask = [.titled, .closable, .miniaturizable] - win.setContentSize(NSSize(width: 680, height: 560)) + win.setContentSize(NSSize(width: 760, height: 680)) win.center() win.isReleasedWhenClosed = false settingsWindow = win diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index dbf178c..0bdc02d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -1,88 +1,343 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers struct SettingsView: View { - private enum Section: String, CaseIterable, Identifiable { - case general - case privacy - case about - - var id: Self { self } - - var title: String { - switch self { - case .general: "General" - case .privacy: "Privacy" - case .about: "About" + @State private var section: SettingsSection = .general + + var body: some View { + HStack(spacing: 0) { + settingsSidebar + Divider() + + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + .font(.system(size: 20, weight: .bold)) + Text(section.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 26) + .padding(.vertical, 18) + + Divider() + content } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) } + .frame(width: 760, height: 680) + .background(Color(nsColor: .windowBackgroundColor)) + } - var symbol: String { - switch self { - case .general: "gearshape" - case .privacy: "hand.raised" - case .about: "info.circle" + private var settingsSidebar: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 9) { + Image(nsImage: NSApp.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + Text("Pesty") + .font(.system(size: 16, weight: .bold)) + } + .padding(.bottom, 18) + + ForEach(SettingsSection.allCases) { item in + Button { section = item } label: { + Label(item.title, systemImage: item.symbol) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + section == item ? Color.accentColor.opacity(0.16) : .clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) } + + Spacer() + + Text("Pesty \(Bundle.main.appVersion)") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) } + .padding(16) + .frame(width: 174) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor)) } - @State private var section: Section = .general + @ViewBuilder + private var content: some View { + switch section { + case .general: + GeneralSettings() + case .privacy: + PrivacySettings() + case .shortcuts: + ShortcutsSettings() + case .sync: + SyncSettings() + case .about: + AboutView() + } + } +} + +private enum SettingsSection: CaseIterable, Identifiable { + case general + case privacy + case shortcuts + case sync + case about + + var id: Self { self } + + var title: String { + switch self { + case .general: "General" + case .privacy: "Privacy" + case .shortcuts: "Shortcuts" + case .sync: "Sync" + case .about: "About" + } + } + + var subtitle: String { + switch self { + case .general: "History, behavior, and app preferences" + case .privacy: "Keep clips from selected apps out of Pesty" + case .shortcuts: "Keyboard controls for Pesty and Quick Paste" + case .sync: "Keep your clipboard history available on every Mac" + case .about: "Pesty for macOS" + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .privacy: "hand.raised" + case .shortcuts: "keyboard" + case .sync: "icloud" + case .about: "info.circle" + } + } +} + +private struct GeneralSettings: View { + @Bindable private var settings = Settings.shared + + #if !MAS + @State private var accessibilityGranted = AXIsProcessTrusted() + @State private var requestedGrant = false + + private let poll = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + #endif var body: some View { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 10) { - Image(nsImage: NSApp.applicationIconImage ?? NSImage()) - .resizable() - .frame(width: 30, height: 30) - Text("Pesty") - .font(.headline) - } - .padding(.horizontal, 16) - .padding(.top, 18) - .padding(.bottom, 14) - - ForEach(Section.allCases) { item in - Button { - section = item - } label: { - Label(item.title, systemImage: item.symbol) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(Rectangle()) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Keep History") { + SettingsSurface { + VStack(alignment: .leading, spacing: 14) { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + .font(.system(size: 14)) + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + .font(.system(size: 14)) + Spacer() + Text(settings.historyRetention.title) + .font(.system(size: 14, weight: .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(.system( + size: 10, + weight: retention == settings.historyRetention ? .bold : .medium + )) + .foregroundStyle( + retention == settings.historyRetention ? Color.accentColor : .secondary + ) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + Text("Erase saved clips now") + .font(.system(size: 14)) + Spacer() + Button("Erase History…", role: .destructive) { + ClipboardStore.shared.clearHistory() + } + } + } } - .buttonStyle(.plain) - .foregroundStyle(section == item ? .primary : .secondary) - .background { - if section == item { - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) + } + + SettingsFormGroup("Behavior") { + SettingsSurface { + VStack(alignment: .leading, spacing: 0) { + #if !MAS + settingToggle("Paste directly into the active app", isOn: $settings.pasteDirectly) + Divider() + #endif + settingToggle("Play sound on paste", isOn: $settings.playSound) + Divider() + settingToggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) + Divider() + settingToggle("Launch at login", isOn: $settings.launchAtLogin) + Divider() + settingToggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) + Divider() + settingToggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) + Divider() + + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") + .font(.system(size: 14)) + Slider(value: $settings.barHeight, in: 300...720, step: 10) + } + .padding(.vertical, 12) + + #if MAS + Text("Select a clip to copy it, then press ⌘V to paste it into your app.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + #endif } } } - Spacer() - } - .padding(.horizontal, 8) - .frame(width: 180) - .background(Color(nsColor: .windowBackgroundColor)) + SettingsFormGroup("Clip Previews") { + SettingsSurface { + Picker("Preview style", selection: $settings.clipPreviewStyle) { + ForEach(ClipPreviewStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .font(.system(size: 14)) + .padding(.vertical, 9) - Divider() + Divider() + + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + } + } - Group { - switch section { - case .general: - GeneralSettings() - case .privacy: - PrivacySettings() - case .about: - AboutView() + #if !MAS + SettingsFormGroup("Accessibility") { + SettingsSurface { + HStack(spacing: 12) { + Image(systemName: accessibilityGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(accessibilityGranted ? .green : .orange) + .font(.title3) + VStack(alignment: .leading, spacing: 3) { + Text(accessibilityGranted ? "Accessibility enabled" : "Accessibility required") + .font(.system(size: 14, weight: .medium)) + Text( + accessibilityGranted + ? "Direct paste is ready to use." + : (requestedGrant + ? "Waiting for approval in System Settings." + : "Required to paste directly into other apps.") + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !accessibilityGranted { + Button("Open Settings") { + requestedGrant = true + PasteService.ensureAccessibility(prompt: true) + openAccessibilityPane() + } + } else if requestedGrant { + Button("Restart Pesty") { AppController.restart() } + } + } + } } + #endif } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) } - .frame(width: 680, height: 560) + #if !MAS + .onAppear { accessibilityGranted = AXIsProcessTrusted() } + .onReceive(poll) { _ in + let now = AXIsProcessTrusted() + if now != accessibilityGranted { accessibilityGranted = now } + } + #endif + } + + #if !MAS + private func openAccessibilityPane() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + #endif + + private func settingToggle(_ title: String, isOn: Binding) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.system(size: 14)) + Spacer(minLength: 16) + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } + + private var retentionSliderValue: Binding { + Binding( + get: { settings.historyRetention.sliderIndex }, + set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } + ) } } @@ -90,29 +345,58 @@ private struct PrivacySettings: View { @Bindable private var settings = Settings.shared var body: some View { - Form { - Section("Excluded Apps") { - Text("Pesty will not save anything copied while one of these apps is active. This is useful for password managers such as 1Password.") - .font(.caption) - .foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Excluded Apps") { + SettingsSurface { + Text("Pesty will not save anything copied while one of these apps is the source. This is useful for password managers such as 1Password.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, settings.ignoredSourceAppBundleIDs.isEmpty ? 12 : 8) + + if settings.ignoredSourceAppBundleIDs.isEmpty { + ContentUnavailableView( + "No apps excluded", + systemImage: "hand.raised", + description: Text("Add an app to keep its copied content out of Pesty.") + ) + .font(.system(size: 12)) + .padding(.vertical, 14) + } else { + ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in + Divider() + ignoredAppRow(bundleID) + } + } + + Divider() - if settings.ignoredSourceAppBundleIDs.isEmpty { - ContentUnavailableView("No apps excluded", - systemImage: "hand.raised", - description: Text("Add an app to keep its copied content out of Pesty.")) - .padding(.vertical, 12) - } else { - ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in - ignoredAppRow(bundleID) + Button { chooseApps() } label: { + Label("Add App…", systemImage: "plus") + } + .padding(.vertical, 10) } } - Button { chooseApps() } label: { - Label("Add App…", systemImage: "plus") + SettingsFormGroup("Concealed Clips") { + SettingsSurface { + Toggle("Ignore concealed clipboard content", isOn: $settings.ignoreConcealed) + .font(.system(size: 14)) + .toggleStyle(.switch) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Text("Pesty also respects the standard macOS concealed-clipboard marker used by password managers.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } } } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) } - .formStyle(.grouped) } private func ignoredAppRow(_ bundleID: String) -> some View { @@ -137,7 +421,7 @@ private struct PrivacySettings: View { .buttonStyle(.plain) .help("Allow clips from \(applicationName(for: bundleID))") } - .padding(.vertical, 4) + .padding(.vertical, 7) } private func chooseApps() { @@ -150,6 +434,7 @@ private struct PrivacySettings: View { panel.allowsMultipleSelection = true panel.allowedContentTypes = [.applicationBundle] guard panel.runModal() == .OK else { return } + for url in panel.urls { guard let bundleID = Bundle(url: url)?.bundleIdentifier, bundleID != Bundle.main.bundleIdentifier else { continue } @@ -166,191 +451,138 @@ private struct PrivacySettings: View { } } -private struct GeneralSettings: View { +private struct ShortcutsSettings: View { @Bindable private var settings = Settings.shared - #if !MAS - @State private var accessibilityGranted = AXIsProcessTrusted() - @State private var requestedGrant = false - - private let poll = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - #endif var body: some View { - Form { - Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } - } - - Section("History") { - Picker("Keep history by", selection: $settings.historyRetentionMode) { - ForEach(HistoryRetentionMode.allCases) { mode in - Text(mode.title).tag(mode) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Open Pesty") { + SettingsSurface { + LabeledContent("Show the Pesty bar") { + HotkeyRecorderView() + } + .font(.system(size: 14)) + .padding(.vertical, 9) } } - .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) + SettingsFormGroup("Quick Paste") { + SettingsSurface { + VStack(spacing: 0) { + LabeledContent("Paste items 1–9") { + HStack(spacing: 6) { + ShortcutModifierPicker(selection: $settings.quickPasteModifier) + Text("+ 1…9") + .foregroundStyle(.secondary) + } + } + .font(.system(size: 14)) + .padding(.vertical, 10) + + Divider() + + LabeledContent("Paste as plain text") { + ShortcutModifierPicker(selection: $settings.plainTextModifier) } + .font(.system(size: 14)) + .padding(.vertical, 10) } - } - Text(settings.historyRetention.description) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Section("Quick Paste") { - LabeledContent("Paste items 1–9") { - HStack(spacing: 6) { - modifierPicker(selection: $settings.quickPasteModifier) - Text("+ 1…9").foregroundStyle(.secondary) + Text("Hold the plain-text modifier while using Quick Paste to remove formatting. With the defaults, ⌘⇧1 pastes the first item as plain text.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, 8) } } - LabeledContent("Paste as plain text") { - modifierPicker(selection: $settings.plainTextModifier) - } - Text("Hold the plain-text modifier while using Quick Paste to remove formatting. For example, ⌘⇧1 pastes the first item as plain text with the default shortcuts.") - .font(.caption) - .foregroundStyle(.secondary) } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} - Section("Behavior") { - #if !MAS - Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) - .toggleStyle(.switch) - #endif - Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) - .toggleStyle(.switch) - Toggle("Play sound on paste", isOn: $settings.playSound) - .toggleStyle(.switch) - Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) - .toggleStyle(.switch) - Toggle("Launch at login", isOn: $settings.launchAtLogin) - .toggleStyle(.switch) - Toggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) - .toggleStyle(.switch) - Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) - .toggleStyle(.switch) - VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") - Slider(value: $settings.barHeight, in: 300...720, step: 10) - } - #if MAS - Text("Select a clip to copy it, then press ⌘V to paste it into your app.") - .font(.caption).foregroundStyle(.secondary) - #endif - } +private struct SyncSettings: View { + @Bindable private var settings = Settings.shared - Section("Clip previews") { - Picker("Preview style", selection: $settings.clipPreviewStyle) { - ForEach(ClipPreviewStyle.allCases) { style in - Text(style.title).tag(style) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("iCloud Drive") { + SettingsSurface { + Toggle("Sync clipboard via iCloud Drive", isOn: Binding( + get: { settings.iCloudSync }, + set: { _ in AppController.shared.toggleICloudSync() } + )) + .font(.system(size: 14)) + .toggleStyle(.switch) + .padding(.vertical, 10) + + Text( + ClipboardStore.shared.iCloudAvailable + ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." + : "Sign in to iCloud and enable iCloud Drive to use sync." + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) } } - .pickerStyle(.segmented) - Text(settings.clipPreviewStyle.detail) - .font(.caption) - .foregroundStyle(.secondary) } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} - Section("Sync") { - Toggle("Sync clipboard via iCloud Drive", isOn: Binding( - get: { settings.iCloudSync }, - set: { _ in AppController.shared.toggleICloudSync() })) - .toggleStyle(.switch) - Text(ClipboardStore.shared.iCloudAvailable - ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." - : "Sign in to iCloud and enable iCloud Drive to use sync.") - .font(.caption).foregroundStyle(.secondary) - } +private struct SettingsFormGroup: View { + let title: String + @ViewBuilder let content: Content - #if !MAS - Section("Permissions") { - HStack(spacing: 10) { - Image(systemName: accessibilityGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundStyle(accessibilityGranted ? .green : .orange) - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text("Accessibility") - Text(accessibilityGranted - ? "Granted — direct paste is enabled." - : (requestedGrant - ? "Waiting… toggle Pesty on in System Settings." - : "Required to paste directly into other apps.")) - .font(.caption) - .foregroundStyle(accessibilityGranted ? .green : .secondary) - } - Spacer() - if !accessibilityGranted { - Button("Open Settings") { - requestedGrant = true - PasteService.ensureAccessibility(prompt: true) - openAccessibilityPane() - } - } else if requestedGrant { - Button("Restart Pesty") { AppController.restart() } - } - } - } - #endif + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } - Section("Data") { - Button("Clear Clipboard History", role: .destructive) { - ClipboardStore.shared.clearHistory() - } - } - } - .formStyle(.grouped) - #if !MAS - .onAppear { accessibilityGranted = AXIsProcessTrusted() } - .onReceive(poll) { _ in - let now = AXIsProcessTrusted() - if now != accessibilityGranted { accessibilityGranted = now } + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + content } - #endif } +} - #if !MAS - private func openAccessibilityPane() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) - } +private struct SettingsSurface: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() } - #endif - private var retentionSliderValue: Binding { - Binding( - get: { settings.historyRetention.sliderIndex }, - set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } - ) + var body: some View { + VStack(alignment: .leading, spacing: 0) { content } + .padding(.horizontal, 16) + .padding(.vertical, 4) + .background( + Color(nsColor: .windowBackgroundColor), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.primary.opacity(0.08)) + } } +} + +private struct ShortcutModifierPicker: View { + @Binding var selection: Int - private func modifierPicker(selection: Binding) -> some View { - Picker("", selection: selection) { + var body: some View { + Picker("", selection: $selection) { ForEach(ShortcutModifier.allCases) { modifier in Text("\(modifier.symbol) \(modifier.title)").tag(modifier.carbonValue) } @@ -365,10 +597,13 @@ private struct AboutView: View { var body: some View { VStack(spacing: 12) { Image(nsImage: NSApp.applicationIconImage ?? NSImage()) - .resizable().frame(width: 88, height: 88) - Text("Pesty").font(.system(size: 26, weight: .bold)) + .resizable() + .frame(width: 88, height: 88) + Text("Pesty") + .font(.system(size: 26, weight: .bold)) Text("Version \(Bundle.main.appVersion)") - .font(.subheadline).foregroundStyle(.secondary) + .font(.subheadline) + .foregroundStyle(.secondary) Text("A free, open-source clipboard manager for macOS.\nInspired by Paste.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) @@ -384,7 +619,8 @@ private struct AboutView: View { .padding(.top, 8) Spacer() Text("MIT Licensed · Made with SwiftUI") - .font(.caption).foregroundStyle(.tertiary) + .font(.caption) + .foregroundStyle(.tertiary) } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) From 62192090fdd4b9b70d66260f666934b5976a076b Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:29:10 -0700 Subject: [PATCH 23/44] Persist saved Paste Stacks --- Sources/Pesty/AppController.swift | 8 +- Sources/Pesty/Settings/Settings.swift | 9 ++ Sources/Pesty/Settings/SettingsView.swift | 4 + Sources/Pesty/Store/ClipboardStore.swift | 44 ++++- Sources/Pesty/Store/PasteSequence.swift | 188 ++++++++++++++++++++-- Sources/Pesty/UI/BarView.swift | 12 +- Sources/Pesty/UI/PasteStackDeckCard.swift | 22 ++- Sources/Pesty/UI/PasteStackView.swift | 29 +++- 8 files changed, 286 insertions(+), 30 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index f481daf..f2cf6fe 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -195,10 +195,14 @@ final class AppController: NSObject, NSApplicationDelegate { pasteStackController?.hide() } - func showPasteStackTab() { + func showPasteStackTab(stackID: UUID? = nil) { store.searchText = "" store.source = .pasteStack - pasteSequence.selectFirst() + if let stackID { + pasteSequence.selectStack(stackID) + } else { + pasteSequence.selectFirst() + } pasteStackController?.hide() if barController?.window?.isVisible != true { showBar(source: .pasteStack) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c572989..77ab257 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -120,6 +120,7 @@ final class Settings { static let historyLimit = "historyLimit" static let historyRetentionMode = "historyRetentionMode" static let historyRetention = "historyRetention" + static let pasteStacksFollowHistory = "pasteStacksFollowHistory" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" @@ -160,6 +161,12 @@ final class Settings { } } + /// When enabled, removing clipboard history also removes those clips from + /// saved Paste Stacks. The default keeps stacks until the user deletes them. + var pasteStacksFollowHistory: Bool { + didSet { guard isLoaded else { return }; d.set(pasteStacksFollowHistory, forKey: Keys.pasteStacksFollowHistory) } + } + var hotkeyKeyCode: Int { didSet { guard isLoaded else { return } d.set(hotkeyKeyCode, forKey: Keys.hotkeyKeyCode); HotKeyCenter.shared.reload() } @@ -219,6 +226,7 @@ final class Settings { Keys.historyLimit: 500, Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, Keys.historyRetention: HistoryRetention.month.rawValue, + Keys.pasteStacksFollowHistory: false, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, @@ -234,6 +242,7 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month + pasteStacksFollowHistory = d.bool(forKey: Keys.pasteStacksFollowHistory) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index d28fca9..e75cbf3 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -81,6 +81,10 @@ private struct GeneralSettings: View { Text("Start a Paste Stack from the strip, then copy clips in another app. Use this shortcut to paste each clip in order.") .font(.caption) .foregroundStyle(.secondary) + Toggle("Remove saved stacks with clipboard history", isOn: $settings.pasteStacksFollowHistory) + Text("When enabled, deleting or retaining clipboard history also removes the matching saved stack clips.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Behavior") { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index a40cdf6..12ef1d3 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -78,7 +78,13 @@ final class ClipboardStore { base = pinboards.first(where: { $0.id == id })?.items ?? [] } let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return base } + guard !q.isEmpty else { + // Each saved Paste Stack is represented by one deck card on + // Clipboard. Its clips remain in history for persistence, but do + // not also appear as individual Clipboard cards. + guard case .history = source, PasteSequence.shared.hasSavedStacks else { return base } + return base.filter { !PasteSequence.shared.containsHistoryItemID($0.id) } + } return base.filter { $0.searchableText.contains(q) } } @@ -110,6 +116,10 @@ final class ClipboardStore { func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } + func pasteStacksDidChange() { + scheduleSave() + } + @discardableResult private func applyHistoryPolicyNow() -> Bool { let removed: [ClipItem] @@ -124,6 +134,9 @@ final class ClipboardStore { guard !removed.isEmpty else { return false } history.removeAll { $0.createdAt < cutoff } } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(removed.map(\.id))) + } for item in removed { deleteImageFile(item) } return true } @@ -131,6 +144,9 @@ final class ClipboardStore { func delete(_ item: ClipItem) { history.removeAll { $0.id == item.id } for i in pinboards.indices { pinboards[i].items.removeAll { $0.id == item.id } } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems([item.id]) + } deleteImageFile(item) if selectedID == item.id { selectFirst() } scheduleSave() @@ -140,6 +156,9 @@ final class ClipboardStore { let old = history history.removeAll() selectedID = nil + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(old.map(\.id))) + } for item in old { deleteImageFile(item) } scheduleSave() } @@ -234,6 +253,9 @@ final class ClipboardStore { guard let name = item.imageFileName else { return } let stillUsed = history.contains { $0.imageFileName == name } || pinboards.contains { $0.items.contains { $0.imageFileName == name } } + || PasteSequence.shared.savedStacks.contains { stack in + stack.entries.contains { $0.item.imageFileName == name } + } if stillUsed { return } if let url = imageURL(for: item) { try? FileManager.default.removeItem(at: url) } } @@ -241,6 +263,7 @@ final class ClipboardStore { private struct Snapshot: Codable { var history: [ClipItem] var pinboards: [Pinboard] + var pasteStacks: [SavedPasteStack]? } private func load() { @@ -248,6 +271,7 @@ final class ClipboardStore { let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } history = snap.history pinboards = snap.pinboards + PasteSequence.shared.restoreSavedStacks(snap.pasteStacks ?? []) selectFirst() } @@ -259,7 +283,9 @@ final class ClipboardStore { } func saveNow() { - let snap = Snapshot(history: history, pinboards: pinboards) + let snap = Snapshot(history: history, + pinboards: pinboards, + pasteStacks: PasteSequence.shared.savedStacks) guard let data = try? JSONEncoder().encode(snap) else { return } ignoreWatchUntil = Date().addingTimeInterval(1.5) try? data.write(to: storeURL, options: .atomic) @@ -331,6 +357,20 @@ final class ClipboardStore { pinboards = pinboards.map { byID[$0.id] ?? $0 } + byID.values.filter { b in !pinboards.contains(where: { $0.id == b.id }) } + var stacksByID: [UUID: SavedPasteStack] = Dictionary( + uniqueKeysWithValues: PasteSequence.shared.savedStacks.map { ($0.id, $0) } + ) + for stack in snap.pasteStacks ?? [] { + if let local = stacksByID[stack.id] { + stacksByID[stack.id] = local.updatedAt >= stack.updatedAt ? local : stack + } else { + stacksByID[stack.id] = stack + } + } + PasteSequence.shared.restoreSavedStacks( + stacksByID.values.sorted { $0.createdAt > $1.createdAt } + ) + combined.removeAll() selectFirst() if history.count != before || !snap.history.isEmpty { saveNow() } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index 2fe70af..3033f45 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -1,19 +1,62 @@ import AppKit import Observation -struct PasteStackEntry: Identifiable { - let id = UUID() +struct PasteStackEntry: Identifiable, Codable { + let id: UUID let item: ClipItem - /// Holds an in-memory image while a Stack is active, even if the image is - /// later pruned from clipboard history. + /// This stays in memory only. The persisted clip image remains in + /// ClipboardStore and is loaded again after relaunch. let imagePreview: NSImage? /// Pasted clips remain in the stack so its deck can show progress and be /// reset without asking the user to collect the same clips again. - var isPasted = false + var isPasted: Bool - init(item: ClipItem, imagePreview: NSImage? = nil) { + init(id: UUID = UUID(), item: ClipItem, imagePreview: NSImage? = nil, isPasted: Bool = false) { + self.id = id self.item = item self.imagePreview = imagePreview + self.isPasted = isPasted + } + + private enum CodingKeys: String, CodingKey { case id, item, isPasted } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + item = try container.decode(ClipItem.self, forKey: .item) + isPasted = try container.decode(Bool.self, forKey: .isPasted) + imagePreview = nil + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(item, forKey: .item) + try container.encode(isPasted, forKey: .isPasted) + } +} + +/// One persisted Paste Stack deck. Its entries remain ordered and pasteable, +/// but are represented by a single card in clipboard history. +struct SavedPasteStack: Identifiable, Codable { + let id: UUID + let createdAt: Date + var updatedAt: Date + var entries: [PasteStackEntry] + + init(id: UUID = UUID(), createdAt: Date = .now, entries: [PasteStackEntry] = []) { + self.id = id + self.createdAt = createdAt + self.updatedAt = createdAt + self.entries = entries + } + + var hasEntries: Bool { !entries.isEmpty } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) } } @@ -22,11 +65,16 @@ struct PasteStackEntry: Identifiable { final class PasteSequence { static let shared = PasteSequence() + /// The active stack is mirrored here for the existing collection and paste + /// UI. Every mutation is written back to its SavedPasteStack. private(set) var entries: [PasteStackEntry] = [] private(set) var isCollecting = false private(set) var selectedEntryID: UUID? + private(set) var savedStacks: [SavedPasteStack] = [] + private(set) var activeStackID: UUID? var hasEntries: Bool { !entries.isEmpty } + var hasSavedStacks: Bool { savedStacks.contains(where: \.hasEntries) } var pendingCount: Int { entries.count(where: { !$0.isPasted }) } var pastedCount: Int { entries.count - pendingCount } /// Keep pending clips at the front and previously pasted clips at the end @@ -39,30 +87,62 @@ final class PasteSequence { return entries.first(where: { $0.id == selectedEntryID }) } + func containsHistoryItemID(_ id: UUID) -> Bool { + savedStacks.contains { stack in stack.entries.contains { $0.item.id == id } } + } + private init() {} + func restoreSavedStacks(_ stacks: [SavedPasteStack]) { + savedStacks = stacks.sorted { $0.createdAt > $1.createdAt } + guard let newest = savedStacks.first(where: \.hasEntries) else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + return + } + activate(newest) + } + + func selectStack(_ id: UUID) { + guard let stack = savedStacks.first(where: { $0.id == id }) else { return } + activate(stack) + } + + /// Opens the selected stack for collection without discarding saved decks. func begin() { + ensureActiveStack() isCollecting = true if selectedEntryID == nil { selectFirst() } + persistActiveStack() } func pause() { isCollecting = false + persistActiveStack() } + /// Starts a distinct, empty saved stack. Previous stacks remain as decks. func newStack() { - entries.removeAll() - isCollecting = true + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] selectedEntryID = nil + isCollecting = true + persistActiveStack() } @discardableResult func addIfNeeded(_ item: ClipItem) -> Bool { + ensureActiveStack() guard isCollecting, !entries.contains(where: { $0.item.id == item.id }) else { return false } let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil entries.append(PasteStackEntry(item: item, imagePreview: imagePreview)) if selectedEntryID == nil { selectFirst() } + persistActiveStack() return true } @@ -92,6 +172,7 @@ final class PasteSequence { func next() -> PasteStackEntry? { guard let index = entries.firstIndex(where: { !$0.isPasted }) else { isCollecting = false + persistActiveStack() return nil } return takeEntry(at: index) @@ -105,27 +186,76 @@ final class PasteSequence { } func reAdd(_ entry: PasteStackEntry) { + ensureActiveStack() entries.append(PasteStackEntry(item: entry.item, imagePreview: entry.imagePreview)) selectFirst() + persistActiveStack() } func remove(_ entry: PasteStackEntry) { entries.removeAll { $0.id == entry.id } if selectedEntryID == entry.id { selectFirst() } + persistActiveStack() } func resetProgress() { - for index in entries.indices { - entries[index].isPasted = false - } + for index in entries.indices { entries[index].isPasted = false } isCollecting = false selectFirst() + persistActiveStack() } - func cancel() { - entries.removeAll() + func finishCollecting() { isCollecting = false - selectedEntryID = nil + persistActiveStack() + } + + /// Deletes only the active saved stack; all other deck cards remain. + func cancel() { + guard let activeStackID else { return } + savedStacks.removeAll { $0.id == activeStackID } + if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + self.activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() + } + + func deleteStack(_ id: UUID) { + guard savedStacks.contains(where: { $0.id == id }) else { return } + if activeStackID == id { + cancel() + } else { + savedStacks.removeAll { $0.id == id } + ClipboardStore.shared.pasteStacksDidChange() + } + } + + /// Used when the user elects to have saved stacks follow clipboard + /// retention. Empty stacks are removed with their final history item. + func removeHistoryItems(_ ids: Set) { + guard !ids.isEmpty else { return } + for index in savedStacks.indices { + savedStacks[index].entries.removeAll { ids.contains($0.item.id) } + } + savedStacks.removeAll { !$0.hasEntries } + if let activeStackID, + let active = savedStacks.first(where: { $0.id == activeStackID }) { + entries = active.entries + selectFirst() + } else if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() } private func takeEntry(at index: Int) -> PasteStackEntry { @@ -134,6 +264,36 @@ final class PasteSequence { entries.append(entry) isCollecting = false selectFirst() + persistActiveStack() return entry } + + private func ensureActiveStack() { + if let activeStackID, + savedStacks.contains(where: { $0.id == activeStackID }) { return } + if let saved = savedStacks.first(where: \.hasEntries) { + activate(saved) + return + } + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] + selectedEntryID = nil + } + + private func activate(_ stack: SavedPasteStack) { + activeStackID = stack.id + entries = stack.entries + isCollecting = false + selectFirst() + } + + private func persistActiveStack() { + guard let activeStackID, + let index = savedStacks.firstIndex(where: { $0.id == activeStackID }) else { return } + savedStacks[index].entries = entries + savedStacks[index].updatedAt = .now + ClipboardStore.shared.pasteStacksDidChange() + } } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 060881f..dec21ab 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -6,7 +6,7 @@ struct BarView: View { private var sequence: PasteSequence { AppController.shared.pasteSequence } private var showsStackDeck: Bool { - store.source == .history && store.searchText.isEmpty && sequence.hasEntries + store.source == .history && store.searchText.isEmpty && sequence.hasSavedStacks } var body: some View { @@ -98,7 +98,7 @@ struct BarView: View { private var pasteStackButton: some View { Button { - AppController.shared.beginPasteSequence() + AppController.shared.newPasteStack() } label: { Image(systemName: "rectangle.stack.badge.plus") .font(.system(size: 15, weight: .medium)) @@ -114,8 +114,12 @@ struct BarView: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: Theme.cardSpacing) { if showsStackDeck { - PasteStackDeckCard() - .id("pesty.paste-stack.deck") + ForEach(sequence.savedStacks.filter(\.hasEntries)) { stack in + PasteStackDeckCard(stack: stack, + isActive: stack.id == sequence.activeStackID, + isCollecting: stack.id == sequence.activeStackID && sequence.isCollecting) + .id(stack.id) + } } ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in diff --git a/Sources/Pesty/UI/PasteStackDeckCard.swift b/Sources/Pesty/UI/PasteStackDeckCard.swift index 9398175..a82ada0 100644 --- a/Sources/Pesty/UI/PasteStackDeckCard.swift +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -1,17 +1,18 @@ import SwiftUI -/// A compact representation of the active Paste Stack in the Clipboard strip. -/// The card opens the focused stack tab rather than behaving like a history -/// clip, which keeps collection and sequential paste actions unambiguous. +/// A compact representation of one saved Paste Stack in the Clipboard strip. +/// It opens that stack rather than exposing its clips as history cards. struct PasteStackDeckCard: View { - private var stack: PasteSequence { AppController.shared.pasteSequence } + let stack: SavedPasteStack + let isActive: Bool + let isCollecting: Bool private var nextEntry: PasteStackEntry? { stack.displayEntries.first(where: { !$0.isPasted }) } var body: some View { - Button { AppController.shared.showPasteStackTab() } label: { + Button { AppController.shared.showPasteStackTab(stackID: stack.id) } label: { ZStack(alignment: .topLeading) { if stack.pendingCount > 2 { deckLayer(offset: 12, opacity: 0.20) } if stack.pendingCount > 1 { deckLayer(offset: 6, opacity: 0.34) } @@ -44,7 +45,7 @@ struct PasteStackDeckCard: View { VStack(alignment: .leading, spacing: 1) { Text("Paste Stack") .font(.system(size: 15, weight: .bold)) - Text(stack.isCollecting ? "Collecting clips" : "Ready to paste") + Text(statusText) .font(.system(size: 11)) .foregroundStyle(Theme.headerSubText) } @@ -108,8 +109,15 @@ struct PasteStackDeckCard: View { .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) - .strokeBorder(Theme.selection.opacity(0.7), lineWidth: 2) + .strokeBorder(isActive ? Theme.selection.opacity(0.9) : Theme.selection.opacity(0.45), + lineWidth: isActive ? 2 : 1) } .shadow(color: .black.opacity(0.16), radius: 5, y: 2) } + + private var statusText: String { + if isActive && isCollecting { return "Collecting clips" } + if stack.pendingCount > 0 { return "Ready to paste" } + return "Completed stack" + } } diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 464becb..64e9fa2 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -218,7 +218,9 @@ private struct PasteStackEntryRow: View { } private var previewImage: NSImage? { - if entry.item.type == .image { return entry.imagePreview } + if entry.item.type == .image { + return entry.imagePreview ?? ClipboardStore.shared.loadImage(for: entry.item) + } guard entry.item.type == .file, entry.item.fileURLs.count == 1, let urlString = entry.item.fileURLs.first, @@ -264,6 +266,22 @@ struct PasteStackContentView: View { .font(.system(size: 12)) .foregroundStyle(Theme.textSecondary) } + if savedStacks.count > 1 { + Menu { + ForEach(savedStacks) { saved in + Button(stackLabel(for: saved)) { + stack.selectStack(saved.id) + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Choose a saved Paste Stack") + } Spacer() Button { @@ -352,4 +370,13 @@ struct PasteStackContentView: View { if stack.pendingCount > 0 { return "\(stack.pendingCount) clips ready to paste" } return stack.hasEntries ? "All clips pasted" : "Collection paused" } + + private var savedStacks: [SavedPasteStack] { + stack.savedStacks.filter(\.hasEntries) + } + + private func stackLabel(for saved: SavedPasteStack) -> String { + let state = saved.pendingCount > 0 ? "\(saved.pendingCount) ready" : "completed" + return "\(saved.createdAt.clipRelativeLong) · \(state)" + } } From e0927a595b2a650a15fce231ba7a0ac0882cf78c Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:36:01 -0700 Subject: [PATCH 24/44] Add selectable clip color themes --- Sources/Pesty/Settings/Settings.swift | 41 ++++++++++ Sources/Pesty/Settings/SettingsView.swift | 60 +++++++++++++++ Sources/Pesty/Util/SourceColor.swift | 93 +++++++++++++++++++++-- 3 files changed, 187 insertions(+), 7 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index b1ecc7f..2e178d7 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -23,6 +23,33 @@ enum ClipPreviewStyle: Int, CaseIterable, Identifiable { } } +enum ClipColorTheme: Int, CaseIterable, Identifiable { + case `default` + case vibrant + case accentShades + + var id: Int { rawValue } + + var title: String { + switch self { + case .default: "Default" + case .vibrant: "Vibrant" + case .accentShades: "Accent shades" + } + } + + var detail: String { + switch self { + case .default: + "Use Pesty’s familiar, deterministic mix of card colors." + case .vibrant: + "Match each clip to the dominant color in its source app’s icon." + case .accentShades: + "Give each source app a stable lighter or darker shade of one color." + } + } +} + enum HistoryRetention: Int, CaseIterable, Identifiable { case day case week @@ -197,6 +224,8 @@ final class Settings { static let barHeight = "barHeight" static let showBarResizeHandle = "showBarResizeHandle" static let clipPreviewStyle = "clipPreviewStyle" + static let clipColorTheme = "clipColorTheme" + static let clipColorAccentHex = "clipColorAccentHex" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -293,6 +322,14 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } } + var clipColorTheme: ClipColorTheme { + didSet { guard isLoaded else { return }; d.set(clipColorTheme.rawValue, forKey: Keys.clipColorTheme) } + } + + var clipColorAccentHex: String { + didSet { guard isLoaded else { return }; d.set(clipColorAccentHex, forKey: Keys.clipColorAccentHex) } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -327,6 +364,8 @@ final class Settings { Keys.barHeight: 430.0, Keys.showBarResizeHandle: false, Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, + Keys.clipColorTheme: ClipColorTheme.default.rawValue, + Keys.clipColorAccentHex: "#FF5A9F", Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false @@ -348,6 +387,8 @@ final class Settings { barHeight = d.double(forKey: Keys.barHeight) showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook + clipColorTheme = ClipColorTheme(rawValue: d.integer(forKey: Keys.clipColorTheme)) ?? .default + clipColorAccentHex = d.string(forKey: Keys.clipColorAccentHex) ?? "#FF5A9F" showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 0bdc02d..b58bd21 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,6 +245,59 @@ private struct GeneralSettings: View { } } + SettingsFormGroup("Clip Colors") { + SettingsSurface { + Picker("Color theme", selection: $settings.clipColorTheme) { + ForEach(ClipColorTheme.allCases) { theme in + Text(theme.title).tag(theme) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .padding(.vertical, 9) + + Divider() + + Text(settings.clipColorTheme.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + + if settings.clipColorTheme == .accentShades { + Divider() + + HStack(spacing: 12) { + ColorPicker( + "Base color", + selection: clipColorAccent, + supportsOpacity: false + ) + .font(.system(size: 14)) + + Spacer(minLength: 8) + + HStack(spacing: 4) { + ForEach( + Array(SourceColor.accentShades(for: settings.clipColorAccentHex).enumerated()), + id: \.offset + ) { _, color in + Circle() + .fill(color) + .frame(width: 13, height: 13) + } + } + .accessibilityLabel("Ten stable shades of the selected base color") + } + .padding(.vertical, 10) + + Text("Each source app keeps one of ten deterministic shades, so its cards stay recognizable without drifting too far from your chosen color.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 9) + } + } + } + SettingsFormGroup("Clip Previews") { SettingsSurface { Picker("Preview style", selection: $settings.clipPreviewStyle) { @@ -339,6 +392,13 @@ private struct GeneralSettings: View { set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } ) } + + private var clipColorAccent: Binding { + Binding( + get: { Color(hex: settings.clipColorAccentHex) ?? .pink }, + set: { settings.clipColorAccentHex = $0.hexString } + ) + } } private struct PrivacySettings: View { diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index baa6e2e..a1bc165 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -3,17 +3,96 @@ import SwiftUI @MainActor enum SourceColor { - private static var cache: [String: Color] = [:] - private static let fallback = Color(red: 0.02, green: 0.48, blue: 1.0) + private static let defaultPalette: [Color] = [ + Color(red: 0.85, green: 0.66, blue: 0.22), + Color(red: 0.34, green: 0.56, blue: 0.82), + Color(red: 0.72, green: 0.38, blue: 0.58), + Color(red: 0.27, green: 0.62, blue: 0.55), + Color(red: 0.80, green: 0.40, blue: 0.34), + Color(red: 0.45, green: 0.40, blue: 0.74), + Color(red: 0.49, green: 0.62, blue: 0.30), + Color(red: 0.84, green: 0.52, blue: 0.27), + Color(red: 0.30, green: 0.49, blue: 0.74), + Color(red: 0.62, green: 0.42, blue: 0.30), + Color(red: 0.74, green: 0.36, blue: 0.42), + Color(red: 0.40, green: 0.55, blue: 0.62) + ] + private static let defaultMapKey = "appColorMap" + private static let vibrantFallback = Color(red: 0.02, green: 0.48, blue: 1.0) + private static let shadeOffsets: [Double] = [ + -0.18, -0.14, -0.10, -0.06, -0.02, + 0.02, 0.06, 0.10, 0.14, 0.18 + ] + + private static var defaultMap: [String: Int] = { + UserDefaults.standard.dictionary(forKey: defaultMapKey) as? [String: Int] ?? [:] + }() + private static var vibrantCache: [String: Color] = [:] static func color(for bundleID: String?) -> Color { - guard let id = bundleID, !id.isEmpty else { return fallback } - if let color = cache[id] { return color } - let color = dominantColor(in: AppIconProvider.icon(forBundleID: id)) ?? fallback - cache[id] = color + let id = bundleID?.isEmpty == false ? bundleID! : "unknown" + + return switch Settings.shared.clipColorTheme { + case .default: + defaultColor(for: id) + case .vibrant: + vibrantColor(for: id) + case .accentShades: + accentShade(for: id, accentHex: Settings.shared.clipColorAccentHex) + } + } + + static func accentShades(for accentHex: String) -> [Color] { + shadeOffsets.map { accentShade(offset: $0, accentHex: accentHex) } + } + + private static func defaultColor(for bundleID: String) -> Color { + if let index = defaultMap[bundleID] { + return defaultPalette[index % defaultPalette.count] + } + + let index = defaultMap.count % defaultPalette.count + defaultMap[bundleID] = index + UserDefaults.standard.set(defaultMap, forKey: defaultMapKey) + return defaultPalette[index] + } + + private static func vibrantColor(for bundleID: String) -> Color { + if let color = vibrantCache[bundleID] { return color } + let color = dominantColor(in: AppIconProvider.icon(forBundleID: bundleID)) ?? vibrantFallback + vibrantCache[bundleID] = color return color } + private static func accentShade(for bundleID: String, accentHex: String) -> Color { + let index = stableIndex(for: bundleID, count: shadeOffsets.count) + return accentShade(offset: shadeOffsets[index], accentHex: accentHex) + } + + private static func accentShade(offset: Double, accentHex: String) -> Color { + let nsColor = NSColor(hex: accentHex)?.usingColorSpace(.sRGB) + ?? NSColor.systemPink.usingColorSpace(.sRGB)! + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + + return Color( + hue: Double(hue), + saturation: min(0.95, max(0.58, Double(saturation))), + brightness: min(0.98, max(0.52, Double(brightness) + offset)) + ) + } + + private static func stableIndex(for bundleID: String, count: Int) -> Int { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in bundleID.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return Int(hash % UInt64(count)) + } + private static func dominantColor(in icon: NSImage) -> Color? { let size = 40 let thumbnail = NSImage(size: NSSize(width: size, height: size)) @@ -56,7 +135,7 @@ enum SourceColor { } if weight == 0 { - return darkWeight > 0 ? Color(red: 0.025, green: 0.075, blue: 0.24) : fallback + return darkWeight > 0 ? Color(red: 0.025, green: 0.075, blue: 0.24) : vibrantFallback } let main = NSColor(deviceRed: red / weight, green: green / weight, blue: blue / weight, alpha: 1) From 0175a9e11c0bbf321a3eabbfb0f4569c2796949f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:39:13 -0700 Subject: [PATCH 25/44] Add selected clip position preference --- Sources/Pesty/Settings/Settings.swift | 28 +++++++++++++++++++++++ Sources/Pesty/Settings/SettingsView.swift | 20 ++++++++++++++++ Sources/Pesty/UI/BarView.swift | 12 +++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index b1ecc7f..77c666e 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -23,6 +23,27 @@ enum ClipPreviewStyle: Int, CaseIterable, Identifiable { } } +enum SelectedClipPosition: Int, CaseIterable, Identifiable { + case center + case rightEdge + + var id: Int { rawValue } + + var title: String { + switch self { + case .center: "Center" + case .rightEdge: "Right edge" + } + } + + var detail: String { + switch self { + case .center: "Keep the selected clip centered with surrounding context visible." + case .rightEdge: "Place the selected clip at the far right, like Paste." + } + } +} + enum HistoryRetention: Int, CaseIterable, Identifiable { case day case week @@ -197,6 +218,7 @@ final class Settings { static let barHeight = "barHeight" static let showBarResizeHandle = "showBarResizeHandle" static let clipPreviewStyle = "clipPreviewStyle" + static let selectedClipPosition = "selectedClipPosition" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -293,6 +315,10 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } } + var selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -327,6 +353,7 @@ final class Settings { Keys.barHeight: 430.0, Keys.showBarResizeHandle: false, Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false @@ -348,6 +375,7 @@ final class Settings { barHeight = d.double(forKey: Keys.barHeight) showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook + selectedClipPosition = SelectedClipPosition(rawValue: d.integer(forKey: Keys.selectedClipPosition)) ?? .center showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 0bdc02d..a7e198a 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,6 +245,26 @@ private struct GeneralSettings: View { } } + SettingsFormGroup("Clip Navigation") { + SettingsSurface { + Picker("Selected clip position", selection: $settings.selectedClipPosition) { + ForEach(SelectedClipPosition.allCases) { position in + Text(position.title).tag(position) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .padding(.vertical, 9) + + Divider() + + Text(settings.selectedClipPosition.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + } + } + SettingsFormGroup("Clip Previews") { SettingsSurface { Picker("Preview style", selection: $settings.clipPreviewStyle) { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 967b525..741f69a 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -153,7 +153,13 @@ struct BarView: View { .onChange(of: store.selectedID) { _, id in guard let id else { return } withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { - proxy.scrollTo(id, anchor: .center) + proxy.scrollTo(id, anchor: selectedClipAnchor) + } + } + .onChange(of: settings.selectedClipPosition) { _, _ in + guard let id = store.selectedID else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { + proxy.scrollTo(id, anchor: selectedClipAnchor) } } .overlay { if store.visibleItems.isEmpty { emptyState } } @@ -161,6 +167,10 @@ struct BarView: View { .frame(maxHeight: .infinity) } + private var selectedClipAnchor: UnitPoint { + settings.selectedClipPosition == .rightEdge ? .trailing : .center + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: store.searchText.isEmpty ? "doc.on.clipboard" : "magnifyingglass") From aac8b62a8ea115bc2ba56dc6373b7e2c082d82b9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 07:36:39 -0700 Subject: [PATCH 26/44] Strengthen Accent shade contrast --- Sources/Pesty/Util/SourceColor.swift | 39 +++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index a1bc165..8bc0cff 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -19,9 +19,17 @@ enum SourceColor { ] private static let defaultMapKey = "appColorMap" private static let vibrantFallback = Color(red: 0.02, green: 0.48, blue: 1.0) - private static let shadeOffsets: [Double] = [ - -0.18, -0.14, -0.10, -0.06, -0.02, - 0.02, 0.06, 0.10, 0.14, 0.18 + private static let accentVariants: [AccentVariant] = [ + AccentVariant(hueOffset: -0.055, saturationOffset: 0.08, brightnessOffset: -0.34), + AccentVariant(hueOffset: 0.040, saturationOffset: -0.08, brightnessOffset: -0.27), + AccentVariant(hueOffset: -0.025, saturationOffset: 0.10, brightnessOffset: -0.19), + AccentVariant(hueOffset: 0.020, saturationOffset: -0.10, brightnessOffset: -0.11), + AccentVariant(hueOffset: -0.010, saturationOffset: 0.06, brightnessOffset: -0.03), + AccentVariant(hueOffset: 0.010, saturationOffset: -0.05, brightnessOffset: 0.05), + AccentVariant(hueOffset: -0.020, saturationOffset: 0.10, brightnessOffset: 0.13), + AccentVariant(hueOffset: 0.025, saturationOffset: -0.10, brightnessOffset: 0.21), + AccentVariant(hueOffset: -0.040, saturationOffset: 0.04, brightnessOffset: 0.29), + AccentVariant(hueOffset: 0.055, saturationOffset: -0.14, brightnessOffset: 0.35) ] private static var defaultMap: [String: Int] = { @@ -43,7 +51,7 @@ enum SourceColor { } static func accentShades(for accentHex: String) -> [Color] { - shadeOffsets.map { accentShade(offset: $0, accentHex: accentHex) } + accentVariants.map { accentShade(variant: $0, accentHex: accentHex) } } private static func defaultColor(for bundleID: String) -> Color { @@ -65,11 +73,11 @@ enum SourceColor { } private static func accentShade(for bundleID: String, accentHex: String) -> Color { - let index = stableIndex(for: bundleID, count: shadeOffsets.count) - return accentShade(offset: shadeOffsets[index], accentHex: accentHex) + let index = stableIndex(for: bundleID, count: accentVariants.count) + return accentShade(variant: accentVariants[index], accentHex: accentHex) } - private static func accentShade(offset: Double, accentHex: String) -> Color { + private static func accentShade(variant: AccentVariant, accentHex: String) -> Color { let nsColor = NSColor(hex: accentHex)?.usingColorSpace(.sRGB) ?? NSColor.systemPink.usingColorSpace(.sRGB)! var hue: CGFloat = 0 @@ -77,10 +85,15 @@ enum SourceColor { var brightness: CGFloat = 0 nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + // Normalizing the middle brightness prevents very light or very dark + // user selections from collapsing several variants into the same color. + let middleBrightness = min(0.76, max(0.66, Double(brightness))) + let adjustedHue = (Double(hue) + variant.hueOffset + 1).truncatingRemainder(dividingBy: 1) + return Color( - hue: Double(hue), - saturation: min(0.95, max(0.58, Double(saturation))), - brightness: min(0.98, max(0.52, Double(brightness) + offset)) + hue: adjustedHue, + saturation: min(0.98, max(0.60, Double(saturation) + variant.saturationOffset)), + brightness: min(0.98, max(0.32, middleBrightness + variant.brightnessOffset)) ) } @@ -93,6 +106,12 @@ enum SourceColor { return Int(hash % UInt64(count)) } + private struct AccentVariant { + let hueOffset: Double + let saturationOffset: Double + let brightnessOffset: Double + } + private static func dominantColor(in icon: NSImage) -> Color? { let size = 40 let thumbnail = NSImage(size: NSSize(width: size, height: size)) From bc87eaa4d20d007cf1b237f6f3ed1ee27724631c Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:08:35 -0700 Subject: [PATCH 27/44] docs: clarify screenshot guidance --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc414c4..e606bd0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,13 @@ open packaging/Pesty.app - Keep it dependency-free. Prefer system frameworks (AppKit, SwiftUI, Carbon, ServiceManagement). - Match the existing style. Small, focused changes; no unrelated refactors in the same PR. - Test on both Apple Silicon and Intel where it matters (the release is universal). -- UI changes: include a before/after screenshot of the strip. +- Screenshots: add them when a user-facing UI change needs visual context or when they make review easier; they are not required for every change. ## Pull requests 1. Fork and branch from `main`. 2. Make your change; ensure `swift build` is clean (no warnings). -3. Open a PR with a clear description and screenshots for UI changes. +3. Open a PR with a clear description and any screenshots that are useful for review. ## Good first issues From fc253a4f3ce67734dc2f297b3daa1960c700c0d3 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:10:06 -0700 Subject: [PATCH 28/44] Expand search field while filtering --- Sources/Pesty/UI/BarView.swift | 45 +++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..fe71971 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -1,6 +1,12 @@ import SwiftUI struct BarView: View { + /// Keeps a substantial portion of a phrase visible while searching without + /// forcing the pinboard tabs out of a narrow bar. + private static let preferredSearchWidth: CGFloat = 700 + private static let collapsedSearchWidth: CGFloat = 22 + private static let minimumNonSearchChromeWidth: CGFloat = 230 + @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared @@ -20,18 +26,27 @@ struct BarView: View { } private var topBar: some View { - HStack(spacing: 14) { - syncButton - searchIndicator - PinboardTabs() - .layoutPriority(1) - Spacer(minLength: 8) - moreMenu + GeometryReader { geometry in + HStack(spacing: 14) { + syncButton + searchIndicator(width: searchWidth(in: geometry.size.width)) + PinboardTabs() + .layoutPriority(1) + Spacer(minLength: 8) + moreMenu + } + .padding(.horizontal, 18) + .frame(width: geometry.size.width, height: geometry.size.height) } - .padding(.horizontal, 18) .frame(height: 56) } + private func searchWidth(in barWidth: CGFloat) -> CGFloat { + guard !store.searchText.isEmpty else { return Self.collapsedSearchWidth } + let available = barWidth - (2 * 18) - Self.minimumNonSearchChromeWidth + return min(Self.preferredSearchWidth, max(Self.collapsedSearchWidth, available)) + } + private var syncButton: some View { Button { AppController.shared.toggleICloudSync() @@ -44,7 +59,7 @@ struct BarView: View { .help(settings.iCloudSync ? "iCloud sync on" : "Turn on iCloud sync") } - private var searchIndicator: some View { + private func searchIndicator(width: CGFloat) -> some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 14, weight: .semibold)) @@ -54,17 +69,25 @@ struct BarView: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(Theme.textPrimary) .lineLimit(1) + // Match a native search field's behavior: once a query is + // longer than the field, keep the newest typed text visible. + .truncationMode(.head) + .frame(maxWidth: .infinity, alignment: .leading) Button { store.searchText = ""; store.selectFirst() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)).foregroundStyle(Theme.textTertiary) } .buttonStyle(.plain) + .accessibilityLabel("Clear search") } } .padding(.horizontal, store.searchText.isEmpty ? 0 : 10) - .frame(height: 30) + .frame(width: width, height: 30, alignment: .leading) .background(store.searchText.isEmpty ? Color.clear : Theme.fieldBG, in: Capsule()) - .animation(.easeOut(duration: 0.15), value: store.searchText.isEmpty) + .clipped() + .layoutPriority(store.searchText.isEmpty ? 0 : 2) + .animation(.spring(response: 0.30, dampingFraction: 0.84), value: store.searchText.isEmpty) + .accessibilityLabel(store.searchText.isEmpty ? "Search clipboard history" : "Clipboard history search") } private var moreMenu: some View { From f6511b482d81c79c05cbc2c782b5362ae8beb537 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:16:45 -0700 Subject: [PATCH 29/44] Show bar when Pesty is reopened --- Sources/Pesty/AppController.swift | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..e9ccfcf 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -13,6 +13,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? private var keyMonitor: Any? + private var isReopenPresentationPending = false private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? @@ -51,6 +52,33 @@ final class AppController: NSObject, NSApplicationDelegate { } } + func applicationShouldHandleReopen(_ sender: NSApplication, + hasVisibleWindows flag: Bool) -> Bool { + // Finder, Spotlight, and the Dock send a reopen event when the user + // invokes an app that is already running. The clipboard bar is an + // NSPanel, so AppKit's `hasVisibleWindows` value does not reliably + // describe whether Pesty already has a surface on screen. + guard !hasVisiblePestySurface else { return true } + guard !isReopenPresentationPending else { return false } + + isReopenPresentationPending = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isReopenPresentationPending = false + + // A window can appear while AppKit finishes the reopen event + // (for example, during onboarding). Avoid presenting a second + // Pesty surface in that case. + guard !self.hasVisiblePestySurface else { return } + self.showBar() + } + return false + } + + private var hasVisiblePestySurface: Bool { + NSApp.windows.contains { $0.isVisible && !$0.isMiniaturized } + } + @objc private func appActivated(_ note: Notification) { guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } if app.bundleIdentifier != Bundle.main.bundleIdentifier { @@ -133,6 +161,10 @@ final class AppController: NSObject, NSApplicationDelegate { let front = NSWorkspace.shared.frontmostApplication if front?.bundleIdentifier != Bundle.main.bundleIdentifier { previousApp = front + } else if let lastActiveApp, !lastActiveApp.isTerminated { + // Reopen events arrive after Pesty becomes active, so retain the + // most recently active non-Pesty app as the eventual paste target. + previousApp = lastActiveApp } store.searchText = "" store.source = .history From 815eb961b68e1dbdd161c6c8ca7f294ce9a3c7b2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:30:15 -0700 Subject: [PATCH 30/44] Add Paste Bar settings and pause shortcuts --- README.md | 2 + Sources/Pesty/AppController.swift | 46 ++++++++++++++++++-- Sources/Pesty/Monitor/ClipboardMonitor.swift | 4 ++ Sources/Pesty/UI/BarWindowController.swift | 7 +++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4ecb2e3..14a9866 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ The build is signed with a Developer ID and notarized by Apple, so it opens with | Key | Action | | --- | --- | | `⌘⇧V` | Show / hide the strip (configurable) | +| `⌘⇧S` (with strip open) | Open Settings | +| `⌘⇧P` (with strip open) | Pause / resume clipboard monitoring | | `←` `→` `↑` `↓` | Move selection | | `return` | Paste selected clip | | `⌘1`–`⌘9` | Quick-paste the Nth clip | diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..f2c649b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -11,6 +11,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var barController: BarWindowController? private var statusItem: NSStatusItem? + private var pauseMenuItem: NSMenuItem? private var settingsWindow: NSWindow? private var keyMonitor: Any? @@ -64,15 +65,15 @@ final class AppController: NSObject, NSApplicationDelegate { private func setupStatusItem() { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - if let button = item.button { - button.image = NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: "Pesty") - button.image?.isTemplate = true - } + updateStatusItemIcon(item) let menu = NSMenu() menu.addItem(withTitle: "Open Pesty \(Settings.shared.hotkeyDisplay)", action: #selector(menuOpen), keyEquivalent: "").target = self menu.addItem(.separator()) menu.addItem(withTitle: "Settings…", action: #selector(menuSettings), keyEquivalent: ",").target = self + let pause = menu.addItem(withTitle: "Pause Pesty", action: #selector(menuTogglePause), keyEquivalent: "") + pause.target = self + pauseMenuItem = pause menu.addItem(withTitle: "Clear History", action: #selector(menuClear), keyEquivalent: "").target = self menu.addItem(.separator()) let about = menu.addItem(withTitle: "About Pesty", action: #selector(menuAbout), keyEquivalent: "") @@ -85,9 +86,23 @@ final class AppController: NSObject, NSApplicationDelegate { @objc private func menuOpen() { showBar() } @objc private func menuSettings() { showSettings() } @objc private func menuClear() { store.clearHistory() } + @objc private func menuTogglePause() { togglePestyPause() } @objc private func menuQuit() { NSApp.terminate(nil) } @objc private func menuAbout() { showAbout() } + func togglePestyPause() { + monitor.togglePause() + pauseMenuItem?.title = monitor.isPaused ? "Resume Pesty" : "Pause Pesty" + if let item = statusItem { updateStatusItemIcon(item) } + } + + private func updateStatusItemIcon(_ item: NSStatusItem) { + item.button?.image = NSImage( + systemSymbolName: monitor.isPaused ? "pause.circle" : "doc.on.clipboard", + accessibilityDescription: "Pesty") + item.button?.image?.isTemplate = true + } + func showAbout() { NSApp.activate(ignoringOtherApps: true) NSApp.orderFrontStandardAboutPanel(options: [ @@ -185,6 +200,27 @@ final class AppController: NSObject, NSApplicationDelegate { win.makeKeyAndOrderFront(nil) } + /// Handles commands that only apply while the Paste Bar owns keyboard focus. + /// The panel's key-equivalent path calls this before SwiftUI receives command keys. + func handleBarCommandShortcut(_ event: NSEvent) -> Bool { + guard barController?.window?.isKeyWindow == true else { return false } + + let flags = event.modifierFlags + guard flags.contains(.command), flags.contains(.shift), + !flags.contains(.control), !flags.contains(.option) else { return false } + + switch Int(event.keyCode) { + case kVK_ANSI_S: + showSettings() + return true + case kVK_ANSI_P: + togglePestyPause() + return true + default: + return false + } + } + private func startKeyMonitor() { stopKeyMonitor() keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -198,6 +234,8 @@ final class AppController: NSObject, NSApplicationDelegate { } private func handleKey(_ event: NSEvent) -> NSEvent? { + if handleBarCommandShortcut(event) { return nil } + let code = Int(event.keyCode) let flags = event.modifierFlags let cmd = flags.contains(.command) diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..97bb7b2 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -8,6 +8,7 @@ final class ClipboardMonitor { private var timer: Timer? var suppressUntilChangeCount: Int = -1 + private(set) var isPaused = false init() { lastChangeCount = pasteboard.changeCount @@ -24,10 +25,13 @@ final class ClipboardMonitor { func stop() { timer?.invalidate(); timer = nil } + func togglePause() { isPaused.toggle() } + private func poll() { let current = pasteboard.changeCount guard current != lastChangeCount else { return } lastChangeCount = current + guard !isPaused else { return } if current == suppressUntilChangeCount { return } guard let item = makeItem() else { return } ClipboardStore.shared.addCaptured(item) diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..7fb655a 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -4,6 +4,13 @@ import SwiftUI final class BarPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if AppController.shared.handleBarCommandShortcut(event) { + return true + } + return super.performKeyEquivalent(with: event) + } } @MainActor From 4922251488f54af7c34025e5e0956ca22fbf2ce2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:30:38 -0700 Subject: [PATCH 31/44] Add multi-select bulk deletion --- Sources/Pesty/AppController.swift | 4 +- Sources/Pesty/Store/ClipboardStore.swift | 112 +++++++++++++++++++++-- Sources/Pesty/UI/BarView.swift | 20 +++- Sources/Pesty/UI/ClipCardView.swift | 5 +- 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..318a54c 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -222,13 +222,13 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_RightArrow, kVK_DownArrow: store.moveSelection(by: 1); return nil case kVK_Delete: - if cmd, let sel = store.selectedItem { store.delete(sel); return nil } + if cmd { store.deleteSelected(); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst(); return nil } return nil case kVK_ForwardDelete: - if let sel = store.selectedItem { store.delete(sel) } + store.deleteSelected() return nil default: break diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..842d018 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -17,6 +17,10 @@ final class ClipboardStore { var source: BarSource = .history var searchText: String = "" var selectedID: UUID? + /// Every selected clip in the current strip. `selectedID` remains the + /// primary selection used by keyboard navigation and Return-to-paste. + private(set) var selectedIDs: Set = [] + private var selectionAnchorID: UUID? var historyLimit: Int { get { Settings.shared.historyLimit } @@ -91,14 +95,14 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) - if source == .history && searchText.isEmpty { selectedID = existing.id } + if source == .history && searchText.isEmpty { selectOnly(existing.id) } scheduleSave() return } history.insert(item, at: 0) trimHistory() if source == .history && searchText.isEmpty { - selectedID = item.id + selectOnly(item.id) } scheduleSave() } @@ -113,10 +117,47 @@ final class ClipboardStore { } func delete(_ item: ClipItem) { - history.removeAll { $0.id == item.id } - for i in pinboards.indices { pinboards[i].items.removeAll { $0.id == item.id } } - deleteImageFile(item) - if selectedID == item.id { selectFirst() } + delete([item]) + } + + func deleteSelected() { + let items = visibleItems.filter { selectedIDs.contains($0.id) } + if items.isEmpty, let selectedItem { + delete(selectedItem) + } else { + delete(items) + } + } + + /// Deleting from a selected card follows Finder behavior: delete the + /// entire current selection. A context menu opened on an unselected card + /// only deletes that card. + func deleteSelection(containing item: ClipItem) { + if selectedIDs.contains(item.id) { + deleteSelected() + } else { + delete(item) + } + } + + private func delete(_ items: [ClipItem]) { + let ids = Set(items.map(\.id)) + guard !ids.isEmpty else { return } + + let removed = history.filter { ids.contains($0.id) } + + pinboards.flatMap { $0.items.filter { ids.contains($0.id) } } + history.removeAll { ids.contains($0.id) } + for i in pinboards.indices { pinboards[i].items.removeAll { ids.contains($0.id) } } + for item in removed { deleteImageFile(item) } + + if let selectedID, ids.contains(selectedID) { + selectFirst() + } else { + selectedIDs.subtract(ids) + if let selectionAnchorID, ids.contains(selectionAnchorID) { + self.selectionAnchorID = selectedID + } + } scheduleSave() } @@ -124,6 +165,8 @@ final class ClipboardStore { let old = history history.removeAll() selectedID = nil + selectedIDs = [] + selectionAnchorID = nil for item in old { deleteImageFile(item) } scheduleSave() } @@ -170,16 +213,67 @@ final class ClipboardStore { scheduleSave() } - func selectFirst() { selectedID = visibleItems.first?.id } + func selectFirst() { selectOnly(visibleItems.first?.id) } func moveSelection(by delta: Int) { let items = visibleItems guard !items.isEmpty else { return } guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { - selectedID = items.first?.id; return + selectOnly(items.first?.id); return } let next = max(0, min(items.count - 1, idx + delta)) - selectedID = items[next].id + selectOnly(items[next].id) + } + + /// Applies Finder-style selection to a card click: + /// - a normal click selects only that clip; + /// - Command-click toggles that clip without disturbing the other clips; + /// - Shift-click selects the contiguous range from the selection anchor. + /// Command-Shift-click adds that range to an existing selection. + func select(_ id: UUID, with modifiers: NSEvent.ModifierFlags) { + let items = visibleItems + guard let targetIndex = items.firstIndex(where: { $0.id == id }) else { return } + + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + let command = flags.contains(.command) + + if flags.contains(.shift), + let anchorID = selectionAnchorID ?? selectedID, + let anchorIndex = items.firstIndex(where: { $0.id == anchorID }) { + let range = Set(items[min(anchorIndex, targetIndex)...max(anchorIndex, targetIndex)].map(\.id)) + if command { + selectedIDs.formUnion(range) + } else { + selectedIDs = range + } + selectedID = id + return + } + + if command { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + if selectedID == id { + selectedID = items.first(where: { selectedIDs.contains($0.id) })?.id + } + if selectionAnchorID == id { + selectionAnchorID = selectedID + } + } else { + selectedIDs.insert(id) + selectedID = id + if selectionAnchorID == nil { selectionAnchorID = id } + } + return + } + + selectOnly(id) + } + + private func selectOnly(_ id: UUID?) { + selectedID = id + selectedIDs = id.map { [$0] } ?? [] + selectionAnchorID = id } func imageURL(for item: ClipItem) -> URL? { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..20626cd 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -26,6 +26,9 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if store.selectedIDs.count > 1 { + bulkDeleteButton + } moreMenu } .padding(.horizontal, 18) @@ -86,6 +89,21 @@ struct BarView: View { .fixedSize() } + private var bulkDeleteButton: some View { + let count = store.selectedIDs.count + return Button(role: .destructive) { + store.deleteSelected() + } label: { + Label("Delete \(count)", systemImage: "trash") + .font(.system(size: 12.5, weight: .medium)) + .lineLimit(1) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Delete \(count) selected clips (⌘⌫)") + .accessibilityLabel("Delete \(count) selected clips") + } + private var strip: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { @@ -93,7 +111,7 @@ struct BarView: View { ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in ClipCardView(item: item, index: index, - selected: item.id == store.selectedID) + selected: store.selectedIDs.contains(item.id)) .id(item.id) .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..a02db45 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -29,7 +30,7 @@ struct ClipCardView: View { .contentShape(Rectangle()) .onHover { hovering = $0 } .onTapGesture(count: 2) { AppController.shared.pasteItem(item) } - .onTapGesture { store.selectedID = item.id } + .onTapGesture { store.select(item.id, with: NSEvent.modifierFlags) } .contextMenu { menu } } @@ -196,6 +197,6 @@ struct ClipCardView: View { } } Divider() - Button("Delete", role: .destructive) { store.delete(item) } + Button("Delete", role: .destructive) { store.deleteSelection(containing: item) } } } From c9c792d91a30a3006f0e8f0b76d7392dd53d6e8b Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:31:52 -0700 Subject: [PATCH 32/44] Add clip context menu actions --- Sources/Pesty/AppController.swift | 78 ++++++++++++++++++-- Sources/Pesty/Models/ClipItem.swift | 18 +++++ Sources/Pesty/Monitor/PasteService.swift | 16 ++++- Sources/Pesty/UI/ClipCardView.swift | 88 ++++++++++++++++++----- Sources/Pesty/UI/ClipPreviewView.swift | 90 ++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 Sources/Pesty/UI/ClipPreviewView.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..d09d005 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -12,6 +12,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var barController: BarWindowController? private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? + private var previewWindow: NSWindow? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? @@ -131,7 +132,7 @@ final class AppController: NSObject, NSApplicationDelegate { func showBar() { let front = NSWorkspace.shared.frontmostApplication - if front?.bundleIdentifier != Bundle.main.bundleIdentifier { + if let front, !isPesty(front) { previousApp = front } store.searchText = "" @@ -152,13 +153,30 @@ final class AppController: NSObject, NSApplicationDelegate { func pasteSelected() { guard let item = store.selectedItem else { return } - hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + pasteItem(item) + } + + /// The app that will receive a paste after the floating Pesty panel closes. + /// `previousApp` is captured before the panel activates, while + /// `lastActiveApp` covers menu-bar and reopen paths where it is unavailable. + private var pasteTarget: NSRunningApplication? { + [previousApp, lastActiveApp, NSWorkspace.shared.frontmostApplication] + .compactMap { $0 } + .first { !$0.isTerminated && !isPesty($0) } + } + + var pasteMenuTitle: String { + guard let name = pasteTarget?.localizedName, + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "Paste" + } + return "Paste to \(name)" } - func pasteItem(_ item: ClipItem) { + func pasteItem(_ item: ClipItem, asPlainText: Bool = false) { + let target = pasteTarget hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + PasteService.paste(item, into: target, monitor: monitor, asPlainText: asPlainText) } func copyItem(_ item: ClipItem) { @@ -167,6 +185,56 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func showPreview(for item: ClipItem) { + let host = NSHostingController(rootView: ClipPreviewView(item: item)) + let title = "Preview — \(item.displayTitle)" + + if let window = previewWindow { + window.title = title + window.contentViewController = host + window.makeKeyAndOrderFront(nil) + return + } + + let previewWindow = NSWindow(contentViewController: host) + previewWindow.title = title + previewWindow.styleMask = [.titled, .closable, .miniaturizable, .resizable] + previewWindow.setContentSize(NSSize(width: 540, height: 400)) + previewWindow.minSize = NSSize(width: 400, height: 260) + previewWindow.isReleasedWhenClosed = false + previewWindow.center() + self.previewWindow = previewWindow + previewWindow.makeKeyAndOrderFront(nil) + } + + func showSharePicker(for item: ClipItem) { + let items = shareItems(for: item) + guard !items.isEmpty, + let view = barController?.window?.contentView ?? NSApp.keyWindow?.contentView else { return } + + let picker = NSSharingServicePicker(items: items) + let anchor = NSRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1) + picker.show(relativeTo: anchor, of: view, preferredEdge: .maxY) + } + + private func shareItems(for item: ClipItem) -> [Any] { + switch item.type { + case .image: + return store.loadImage(for: item).map { [$0] } ?? [] + case .file: + let urls = item.fileURLs.compactMap(URL.init(string:)) + return urls.isEmpty ? item.plainText.map { [$0 as NSString] } ?? [] : urls + case .color, .text, .richText, .link: + return item.plainText.map { [$0 as NSString] } ?? [] + } + } + + private func isPesty(_ app: NSRunningApplication) -> Bool { + if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { return true } + guard let bundleID = Bundle.main.bundleIdentifier else { return false } + return app.bundleIdentifier == bundleID + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { diff --git a/Sources/Pesty/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index b3689df..7111ca2 100644 --- a/Sources/Pesty/Models/ClipItem.swift +++ b/Sources/Pesty/Models/ClipItem.swift @@ -44,6 +44,24 @@ struct ClipItem: Identifiable, Codable, Equatable { var charCount: Int { text?.count ?? 0 } + /// The representation used when a clip is explicitly pasted as plain text. + /// Images intentionally do not have one: converting an image to an + /// arbitrary description would be surprising and lossy. + var plainText: String? { + switch type { + case .image: + return nil + case .color: + return colorHex + case .file: + if let text, !text.isEmpty { return text } + let paths = fileURLs.map { URL(string: $0)?.path ?? $0 } + return paths.isEmpty ? nil : paths.joined(separator: "\n") + case .text, .richText, .link: + return text + } + } + var displayTitle: String { if let t = customTitle, !t.isEmpty { return t } switch type { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..b67bc0c 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,7 +5,16 @@ import Carbon.HIToolbox enum PasteService { @discardableResult - static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int { + static func copy(_ item: ClipItem, + asPlainText: Bool = false, + to pasteboard: NSPasteboard = .general) -> Int { + if asPlainText { + guard let text = item.plainText else { return pasteboard.changeCount } + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + return pasteboard.changeCount + } + if item.type == .image { guard let img = ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount @@ -38,8 +47,9 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + asPlainText: Bool = false) { + let change = copy(item, asPlainText: asPlainText) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..9b165bc 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -173,29 +173,83 @@ struct ClipCardView: View { @ViewBuilder private var menu: some View { - Button("Paste") { AppController.shared.pasteItem(item) } - Button("Copy") { AppController.shared.copyItem(item) } + Button { AppController.shared.pasteItem(item) } label: { + Label(AppController.shared.pasteMenuTitle, systemImage: "doc.on.clipboard") + } + .keyboardShortcut(.return, modifiers: []) + + Button { AppController.shared.pasteItem(item, asPlainText: true) } label: { + Label("Paste as Plain Text", systemImage: "text.alignleft") + } + .keyboardShortcut(.return, modifiers: .shift) + .disabled(item.plainText == nil) + + Button { AppController.shared.copyItem(item) } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .keyboardShortcut("c", modifiers: .command) + + Divider() + + Button { renameItem() } label: { + Label("Rename…", systemImage: "pencil") + } + .keyboardShortcut("r", modifiers: .command) + + Button(role: .destructive) { store.delete(item) } label: { + Label("Delete", systemImage: "trash") + } + .keyboardShortcut(.delete, modifiers: []) + Divider() - if !store.pinboards.isEmpty { - Menu("Save to Pinboard") { + + Menu { + if store.pinboards.isEmpty { + Button("No Pinboards Yet") {} + .disabled(true) + } else { ForEach(store.pinboards) { b in - Button(b.name) { store.saveToPinboard(item, boardID: b.id) } + Button { store.saveToPinboard(item, boardID: b.id) } label: { + Label { + Text(b.name) + } icon: { + Image(systemName: "circle.fill") + .foregroundStyle(b.color) + } + } } } - } - Button("Save to New Pinboard…") { - if let name = TextPrompt.run(title: "New Pinboard", message: "Name") { - let b = store.addPinboard(name: name) - store.saveToPinboard(item, boardID: b.id) - } - } - Button("Edit Title…") { - if let t = TextPrompt.run(title: "Edit Title", message: "Card title", - defaultValue: item.customTitle ?? "") { - store.setTitle(t, for: item) + Divider() + Button { pinToNewBoard() } label: { + Label("Create Pinboard…", systemImage: "plus") } + } label: { + Label("Pin", systemImage: "pin") } + Divider() - Button("Delete", role: .destructive) { store.delete(item) } + + Button { AppController.shared.showPreview(for: item) } label: { + Label("Preview", systemImage: "eye") + } + .keyboardShortcut(.space, modifiers: []) + + Button { AppController.shared.showSharePicker(for: item) } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + private func renameItem() { + if let title = TextPrompt.run(title: "Rename", message: "Enter a name", + defaultValue: item.displayTitle) { + store.setTitle(title, for: item) + } + } + + private func pinToNewBoard() { + if let name = TextPrompt.run(title: "Create Pinboard", message: "Name") { + let board = store.addPinboard(name: name) + store.saveToPinboard(item, boardID: board.id) + } } } diff --git a/Sources/Pesty/UI/ClipPreviewView.swift b/Sources/Pesty/UI/ClipPreviewView.swift new file mode 100644 index 0000000..dbfeb79 --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// A read-only preview for a clip. It deliberately keeps editing out of this +/// surface: Rename remains a separate, explicit contextual action. +struct ClipPreviewView: View { + let item: ClipItem + + private var store: ClipboardStore { ClipboardStore.shared } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image(systemName: item.type.symbol) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(item.type.accent) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(item.displayTitle) + .font(.headline) + .lineLimit(2) + Text(item.type.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + ScrollView { + preview + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .padding(20) + .frame(minWidth: 400, minHeight: 260) + } + + @ViewBuilder + private var preview: some View { + switch item.type { + case .image: + if let image = store.loadImage(for: item) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + unavailable("The original image is no longer available.") + } + case .color: + VStack(alignment: .leading, spacing: 12) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(hex: item.colorHex ?? "#000000") ?? .black) + .frame(height: 180) + Text(item.colorHex ?? "Color") + .font(.system(.title3, design: .monospaced)) + .textSelection(.enabled) + } + case .file: + VStack(alignment: .leading, spacing: 10) { + ForEach(item.fileURLs, id: \.self) { value in + let url = URL(string: value) + HStack(alignment: .top, spacing: 9) { + Image(systemName: "doc") + .foregroundStyle(.secondary) + Text(url?.path ?? value) + .textSelection(.enabled) + } + } + } + case .text, .richText, .link: + if let text = item.text, !text.isEmpty { + Text(text) + .font(.system(size: 14)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + unavailable("This clip has no text to preview.") + } + } + } + + private func unavailable(_ message: String) -> some View { + ContentUnavailableView("Preview Unavailable", + systemImage: "eye.slash", + description: Text(message)) + } +} From d13f8ebe7b9ce53c4fe46cd9f55e99fa830f2de1 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:36:04 -0700 Subject: [PATCH 33/44] Fix Paste Bar card alignment --- Sources/Pesty/UI/BarView.swift | 50 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..9889ab4 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -1,6 +1,9 @@ import SwiftUI struct BarView: View { + private static let stripTopInset: CGFloat = 4 + private static let stripBottomInset: CGFloat = 18 + @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared @@ -87,31 +90,36 @@ struct BarView: View { } private var strip: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: Theme.cardSpacing) { - ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in - ClipCardView(item: item, - index: index, - selected: item.id == store.selectedID) - .id(item.id) - .transition(.asymmetric( - insertion: .scale(scale: 0.92).combined(with: .opacity), - removal: .opacity)) + GeometryReader { geometry in + let cardHeight = max(1, geometry.size.height - Self.stripTopInset - Self.stripBottomInset) + + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: Theme.cardSpacing) { + ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in + ClipCardView(item: item, + index: index, + selected: item.id == store.selectedID) + .frame(height: cardHeight) + .id(item.id) + .transition(.asymmetric( + insertion: .scale(scale: 0.92).combined(with: .opacity), + removal: .opacity)) + } } + .padding(.horizontal, 18) + .padding(.top, Self.stripTopInset) + .padding(.bottom, Self.stripBottomInset) + .animation(.spring(response: 0.34, dampingFraction: 0.8), value: store.visibleItems.count) } - .padding(.horizontal, 18) - .padding(.top, 4) - .padding(.bottom, 18) - .animation(.spring(response: 0.34, dampingFraction: 0.8), value: store.visibleItems.count) - } - .onChange(of: store.selectedID) { _, id in - guard let id else { return } - withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { - proxy.scrollTo(id, anchor: .center) + .onChange(of: store.selectedID) { _, id in + guard let id else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { + proxy.scrollTo(id, anchor: .center) + } } + .overlay { if store.visibleItems.isEmpty { emptyState } } } - .overlay { if store.visibleItems.isEmpty { emptyState } } } .frame(maxHeight: .infinity) } From 3335d23820b7b3382d89e813d8d268d495eb2be4 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:36:22 -0700 Subject: [PATCH 34/44] Move history clips into Paste Stack --- Sources/Pesty/AppController.swift | 13 +++++++++++++ Sources/Pesty/Store/PasteSequence.swift | 26 +++++++++++++++++++++++-- Sources/Pesty/UI/ClipCardView.swift | 15 ++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index f2cf6fe..4282552 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -239,6 +239,19 @@ final class AppController: NSObject, NSApplicationDelegate { _ = pasteSequence.addIfNeeded(item) } + /// Adds a Clipboard-history clip to the current Paste Stack. Once moved, + /// the clip is represented by the stack deck instead of a duplicate card + /// in the unfiltered Clipboard strip. + func moveHistoryItemToPasteStack(_ item: ClipItem) { + guard pasteSequence.addHistoryItem(item) else { return } + + if store.source == .history, + store.searchText.isEmpty, + store.selectedID == item.id { + store.selectFirst() + } + } + func removePasteStackEntry(_ entry: PasteStackEntry) { pasteSequence.remove(entry) } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index 3033f45..5197777 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -139,8 +139,22 @@ final class PasteSequence { 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)) + entries.append(makeEntry(for: item)) + if selectedEntryID == nil { selectFirst() } + persistActiveStack() + return true + } + + /// Moves a user-selected history clip into the active Paste Stack. Unlike + /// passive clipboard capture, this also resumes collection so a stack can + /// be assembled directly from items already in Clipboard history. + @discardableResult + func addHistoryItem(_ item: ClipItem) -> Bool { + ensureActiveStack() + guard !containsHistoryItemID(item.id) else { return false } + + isCollecting = true + entries.append(makeEntry(for: item)) if selectedEntryID == nil { selectFirst() } persistActiveStack() return true @@ -268,6 +282,14 @@ final class PasteSequence { return entry } + /// Capture an image preview while the original history item is still + /// readily available. The entry retains the ClipItem for persistence, and + /// the in-memory preview keeps the active Paste Stack visually stable. + private func makeEntry(for item: ClipItem) -> PasteStackEntry { + let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil + return PasteStackEntry(item: item, imagePreview: imagePreview) + } + private func ensureActiveStack() { if let activeStackID, savedStacks.contains(where: { $0.id == activeStackID }) { return } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..522e95f 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -175,6 +175,17 @@ struct ClipCardView: View { private var menu: some View { Button("Paste") { AppController.shared.pasteItem(item) } Button("Copy") { AppController.shared.copyItem(item) } + if store.source == .history { + Button { + AppController.shared.moveHistoryItemToPasteStack(item) + } label: { + Label(isInPasteStack ? "Already in Paste Stack" : "Move to Paste Stack", + systemImage: isInPasteStack + ? "checkmark.circle.fill" + : "rectangle.stack.badge.plus") + } + .disabled(isInPasteStack) + } Divider() if !store.pinboards.isEmpty { Menu("Save to Pinboard") { @@ -198,4 +209,8 @@ struct ClipCardView: View { Divider() Button("Delete", role: .destructive) { store.delete(item) } } + + private var isInPasteStack: Bool { + PasteSequence.shared.containsHistoryItemID(item.id) + } } From c39f06c35988c4bcffd68f274a728e2ef188465f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:37:40 -0700 Subject: [PATCH 35/44] Reorder Paste Stack entries by drag and drop --- Sources/Pesty/Store/PasteSequence.swift | 22 ++++ Sources/Pesty/UI/PasteStackView.swift | 146 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index 3033f45..0be02e6 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -198,6 +198,28 @@ final class PasteSequence { persistActiveStack() } + /// Reorders the still-pending portion of the active Paste Stack. Pasted + /// entries remain grouped at the end of the deck so progress stays clear + /// and the next entry to paste is always the first pending one. + func movePendingEntry(_ entryID: UUID, before destinationID: UUID? = nil) { + var pendingEntries = entries.filter { !$0.isPasted } + guard let sourceIndex = pendingEntries.firstIndex(where: { $0.id == entryID }) else { return } + + let entry = pendingEntries.remove(at: sourceIndex) + if let destinationID { + guard let destinationIndex = pendingEntries.firstIndex(where: { $0.id == destinationID }) else { + return + } + pendingEntries.insert(entry, at: destinationIndex) + } else { + pendingEntries.append(entry) + } + + entries = pendingEntries + entries.filter(\.isPasted) + selectedEntryID = entryID + persistActiveStack() + } + func resetProgress() { for index in entries.indices { entries[index].isPasted = false } isCollecting = false diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 64e9fa2..545df4f 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -1,7 +1,11 @@ import SwiftUI +import UniformTypeIdentifiers struct PasteStackView: View { @Bindable private var stack = PasteSequence.shared + @State private var draggedEntryID: UUID? + @State private var dropTargetEntryID: UUID? + @State private var isDropTargetAtEnd = false var body: some View { VStack(spacing: 0) { @@ -88,6 +92,19 @@ struct PasteStackView: View { index: index + 1, selected: stack.selectedEntryID == entry.id, showsPasteAction: false) + .modifier(PasteStackEntryReorderModifier( + entry: entry, + draggedEntryID: $draggedEntryID, + dropTargetEntryID: $dropTargetEntryID, + isDropTargetAtEnd: $isDropTargetAtEnd + )) + } + if stack.pendingCount > 1 { + PasteStackEntryEndDropTarget( + draggedEntryID: $draggedEntryID, + dropTargetEntryID: $dropTargetEntryID, + isDropTargeted: $isDropTargetAtEnd + ) } } .padding(12) @@ -145,6 +162,13 @@ private struct PasteStackEntryRow: View { var body: some View { HStack(spacing: 10) { + Image(systemName: "line.3.horizontal") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .frame(width: 14) + .opacity(entry.isPasted ? 0 : 1) + .accessibilityHidden(entry.isPasted) + .help(entry.isPasted ? "Pasted clip" : "Drag to reorder Paste Stack") Text("\(index)") .font(.caption.weight(.bold)) .foregroundStyle(entry.item.type.accent) @@ -247,6 +271,9 @@ private struct PasteStackEntryRow: View { /// turning its entries into ordinary clipboard-history cards. struct PasteStackContentView: View { private var stack: PasteSequence { AppController.shared.pasteSequence } + @State private var draggedEntryID: UUID? + @State private var dropTargetEntryID: UUID? + @State private var isDropTargetAtEnd = false var body: some View { VStack(spacing: 0) { @@ -357,6 +384,19 @@ struct PasteStackContentView: View { index: index + 1, selected: stack.selectedEntryID == entry.id, showsPasteAction: true) + .modifier(PasteStackEntryReorderModifier( + entry: entry, + draggedEntryID: $draggedEntryID, + dropTargetEntryID: $dropTargetEntryID, + isDropTargetAtEnd: $isDropTargetAtEnd + )) + } + if stack.pendingCount > 1 { + PasteStackEntryEndDropTarget( + draggedEntryID: $draggedEntryID, + dropTargetEntryID: $dropTargetEntryID, + isDropTargeted: $isDropTargetAtEnd + ) } } .padding(.horizontal, 24) @@ -380,3 +420,109 @@ struct PasteStackContentView: View { return "\(saved.createdAt.clipRelativeLong) · \(state)" } } + +private enum PasteStackEntryDrag { + static let entryType = UTType(exportedAs: "com.greycorelabs.pesty.paste-stack-entry") + + static func provider(for id: UUID) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: entryType.identifier, + visibility: .ownProcess + ) { completion in + completion(id.uuidString.data(using: .utf8), nil) + return nil + } + return provider + } +} + +private struct PasteStackEntryReorderModifier: ViewModifier { + let entry: PasteStackEntry + @Binding var draggedEntryID: UUID? + @Binding var dropTargetEntryID: UUID? + @Binding var isDropTargetAtEnd: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if entry.isPasted { + content + } else { + content + .onDrag { + draggedEntryID = entry.id + return PasteStackEntryDrag.provider(for: entry.id) + } + .onDrop(of: [PasteStackEntryDrag.entryType], isTargeted: isDropTarget) { _ in + guard let draggedEntryID, draggedEntryID != entry.id else { + clearDragState() + return false + } + PasteSequence.shared.movePendingEntry(draggedEntryID, before: entry.id) + clearDragState() + return true + } + .overlay(alignment: .top) { + if dropTargetEntryID == entry.id, draggedEntryID != entry.id { + Capsule() + .fill(Theme.selection) + .frame(height: 3) + .padding(.horizontal, 10) + .offset(y: -3) + } + } + } + } + + private var isDropTarget: Binding { + Binding( + get: { dropTargetEntryID == entry.id }, + set: { isTargeted in + if isTargeted { + dropTargetEntryID = entry.id + isDropTargetAtEnd = false + } else if dropTargetEntryID == entry.id { + dropTargetEntryID = nil + } + } + ) + } + + private func clearDragState() { + draggedEntryID = nil + dropTargetEntryID = nil + isDropTargetAtEnd = false + } +} + +private struct PasteStackEntryEndDropTarget: View { + @Binding var draggedEntryID: UUID? + @Binding var dropTargetEntryID: UUID? + @Binding var isDropTargeted: Bool + + var body: some View { + Capsule() + .fill(isDropTargeted ? Theme.selection : .clear) + .frame(height: isDropTargeted ? 3 : 10) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .onDrop(of: [PasteStackEntryDrag.entryType], isTargeted: $isDropTargeted) { _ in + guard let draggedEntryID else { + clearDragState() + return false + } + PasteSequence.shared.movePendingEntry(draggedEntryID) + clearDragState() + return true + } + .onChange(of: isDropTargeted) { _, isTargeted in + if isTargeted { dropTargetEntryID = nil } + } + } + + private func clearDragState() { + draggedEntryID = nil + dropTargetEntryID = nil + isDropTargeted = false + } +} From ad4c250751b33a6538b5f7a4caa18ee86313ef5f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:37:48 -0700 Subject: [PATCH 36/44] Add editable clips and Writing Tools --- Sources/Pesty/AppController.swift | 25 ++++ Sources/Pesty/Store/ClipboardStore.swift | 81 ++++++++++++ Sources/Pesty/UI/ClipCardView.swift | 19 +++ Sources/Pesty/UI/ClipEditor.swift | 154 +++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 Sources/Pesty/UI/ClipEditor.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d09d005..1ccbfc2 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -13,6 +13,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? private var previewWindow: NSWindow? + private var previewedItemID: UUID? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? @@ -185,9 +186,33 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { + suppressAutoHide = true + defer { suppressAutoHide = false } + + guard let edit = ClipEditor.run(for: item, launchWritingTools: launchWritingTools) else { return } + + let changed: Bool + switch edit { + case let .text(text, richTextData): + changed = store.updateTextContent(text, richTextData: richTextData, for: item) + case let .color(hex): + changed = store.updateColorContent(hex, for: item) + } + guard changed, let updatedItem = store.item(withID: item.id) else { return } + + // The edited content becomes the live clipboard as well. Suppress the + // monitor so this is an in-place change rather than a duplicate entry. + let change = PasteService.copy(updatedItem) + monitor.suppressUntilChangeCount = change + + if previewedItemID == item.id { showPreview(for: updatedItem) } + } + func showPreview(for item: ClipItem) { let host = NSHostingController(rootView: ClipPreviewView(item: item)) let title = "Preview — \(item.displayTitle)" + previewedItemID = item.id if let window = previewWindow { window.title = title diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..c370bfb 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -170,6 +170,87 @@ final class ClipboardStore { scheduleSave() } + /// Finds the current version of a clip after an edit. Pinboard items retain + /// the history item's identity, so changing a clip can update every saved + /// copy without matching unrelated clips that happen to have the same text. + func item(withID id: UUID) -> ClipItem? { + if let item = history.first(where: { $0.id == id }) { return item } + return pinboards.lazy.flatMap(\.items).first(where: { $0.id == id }) + } + + /// Updates the saved payload of a text-like clip while retaining its + /// identity, source attribution, creation date, and optional card title. + @discardableResult + func updateTextContent(_ text: String, richTextData: Data? = nil, for item: ClipItem) -> Bool { + guard [.text, .richText, .link].contains(item.type), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + + let type: ClipType = richTextData != nil ? .richText : (isWebLink(text) ? .link : .text) + return updateContent(for: item) { existing in + var updated = existing + updated.type = type + updated.text = text + updated.rtfData = richTextData + updated.colorHex = nil + return updated + } + } + + /// Updates a color clip with a normalized sRGB hex value. + @discardableResult + func updateColorContent(_ hex: String, for item: ClipItem) -> Bool { + guard item.type == .color, let color = NSColor(hex: hex) else { return false } + let normalizedHex = color.hexString + return updateContent(for: item) { existing in + var updated = existing + updated.type = .color + updated.text = nil + updated.rtfData = nil + updated.colorHex = normalizedHex + return updated + } + } + + @discardableResult + private func updateContent(for item: ClipItem, + transform: (ClipItem) -> ClipItem) -> Bool { + var changed = false + + if let i = history.firstIndex(where: { $0.id == item.id }) { + let updated = transform(history[i]) + if updated != history[i] { + history[i] = updated + changed = true + } + } + + for boardIndex in pinboards.indices { + for itemIndex in pinboards[boardIndex].items.indices + where pinboards[boardIndex].items[itemIndex].id == item.id { + let updated = transform(pinboards[boardIndex].items[itemIndex]) + if updated != pinboards[boardIndex].items[itemIndex] { + pinboards[boardIndex].items[itemIndex] = updated + changed = true + } + } + } + + guard changed else { return false } + if selectedItem == nil { selectFirst() } + scheduleSave() + return true + } + + private func isWebLink(_ text: String) -> Bool { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.contains(" "), !value.contains("\n"), + let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil else { return false } + return true + } + func selectFirst() { selectedID = visibleItems.first?.id } func moveSelection(by delta: Int) { diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 9b165bc..8a33f38 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -191,6 +192,18 @@ struct ClipCardView: View { Divider() + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") + } + .keyboardShortcut("e", modifiers: .command) + + if writingToolsAvailable { + Button { AppController.shared.editItem(item, launchWritingTools: true) } label: { + Label("Writing Tools", systemImage: "pencil.and.scribble") + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + } + Button { renameItem() } label: { Label("Rename…", systemImage: "pencil") } @@ -252,4 +265,10 @@ struct ClipCardView: View { store.saveToPinboard(item, boardID: board.id) } } + + private var writingToolsAvailable: Bool { + guard [.text, .richText, .link].contains(item.type) else { return false } + guard #available(macOS 15.2, *) else { return false } + return NSWritingToolsCoordinator.isWritingToolsAvailable + } } diff --git a/Sources/Pesty/UI/ClipEditor.swift b/Sources/Pesty/UI/ClipEditor.swift new file mode 100644 index 0000000..ac67486 --- /dev/null +++ b/Sources/Pesty/UI/ClipEditor.swift @@ -0,0 +1,154 @@ +import AppKit + +/// Native modal editing for a clip's payload, separate from the card title. +/// Persistence and pasteboard updates stay in AppController and ClipboardStore. +@MainActor +enum ClipEditor { + enum Edit { + case text(String, richTextData: Data?) + case color(String) + } + + static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? { + switch item.type { + case .text, .richText, .link: + return editText(item, launchWritingTools: launchWritingTools) + case .color: + return editColor(item) + case .image, .file: + showUnsupportedEditor(for: item) + return nil + } + } + + private static func editText(_ item: ClipItem, launchWritingTools: Bool) -> Edit? { + let isRichText = item.type == .richText + let alert = NSAlert() + alert.messageText = "Edit \(item.type.label)" + alert.informativeText = isRichText + ? "Edit this saved clip. Its rich-text formatting is preserved when possible." + : "Edit this saved clip's contents." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + textView.isRichText = isRichText + textView.importsGraphics = false + textView.allowsUndo = true + textView.isEditable = true + textView.isSelectable = true + textView.font = .systemFont(ofSize: 13) + textView.textContainer?.containerSize = NSSize(width: 430, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + if #available(macOS 15.0, *) { + textView.writingToolsBehavior = .complete + } + + if isRichText, + let data = item.rtfData, + let value = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil + ) { + textView.textStorage?.setAttributedString(value) + } else { + textView.string = item.text ?? "" + } + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.documentView = textView + alert.accessoryView = scrollView + alert.window.initialFirstResponder = textView + + if launchWritingTools { + DispatchQueue.main.async { + guard #available(macOS 15.2, *), + NSWritingToolsCoordinator.isWritingToolsAvailable else { return } + alert.window.makeFirstResponder(textView) + textView.showWritingTools(nil) + } + } + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let text = textView.string + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + showEmptyTextWarning() + return nil + } + + let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) + let richTextData = isRichText ? textView.rtf(from: range) : nil + return .text(text, richTextData: richTextData) + } + + private static func editColor(_ item: ClipItem) -> Edit? { + let alert = NSAlert() + alert.messageText = "Edit Color" + alert.informativeText = "Choose the color stored in this saved clip." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let color = item.colorHex.flatMap(NSColor.init(hex:)) ?? .black + let accessory = ColorEditorAccessoryView(color: color) + alert.accessoryView = accessory + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + return .color(accessory.selectedHex) + } + + private static func showUnsupportedEditor(for item: ClipItem) { + let alert = NSAlert() + alert.messageText = "This clip can't be edited" + alert.informativeText = "Pesty can edit text, rich text, links, and colors. \(item.type.label) clips are kept as-is." + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private static func showEmptyTextWarning() { + let alert = NSAlert() + alert.messageText = "Clip content can't be empty" + alert.informativeText = "Enter some text before saving this clip." + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +@MainActor +private final class ColorEditorAccessoryView: NSStackView { + private let colorWell: NSColorWell + private let valueLabel: NSTextField + + init(color: NSColor) { + colorWell = NSColorWell() + valueLabel = NSTextField(labelWithString: color.hexString) + super.init(frame: NSRect(x: 0, y: 0, width: 260, height: 32)) + + orientation = .horizontal + alignment = .centerY + spacing = 10 + + let label = NSTextField(labelWithString: "Color:") + valueLabel.font = .monospacedSystemFont(ofSize: 12, weight: .medium) + valueLabel.textColor = .secondaryLabelColor + colorWell.color = color + colorWell.target = self + colorWell.action = #selector(colorDidChange) + colorWell.widthAnchor.constraint(equalToConstant: 42).isActive = true + + addArrangedSubview(label) + addArrangedSubview(colorWell) + addArrangedSubview(valueLabel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + var selectedHex: String { colorWell.color.hexString } + + @objc private func colorDidChange() { + valueLabel.stringValue = selectedHex + } +} From 48c8b5bea5089962984090d308fa4bc5cc4b6fb9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:38:35 -0700 Subject: [PATCH 37/44] Use a distinct Rename menu icon --- Sources/Pesty/UI/ClipCardView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 8a33f38..b231928 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -205,7 +205,7 @@ struct ClipCardView: View { } Button { renameItem() } label: { - Label("Rename…", systemImage: "pencil") + Label("Rename…", systemImage: "pencil.line") } .keyboardShortcut("r", modifiers: .command) From 3020ca2dc2e69dcd4e559b3911b094c313dcb010 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:44:49 -0700 Subject: [PATCH 38/44] Keep native editing keys inside the editor --- Sources/Pesty/AppController.swift | 11 ++++++++++- Sources/Pesty/UI/ClipCardView.swift | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 1ccbfc2..c350b09 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -188,7 +188,16 @@ final class AppController: NSObject, NSApplicationDelegate { func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { suppressAutoHide = true - defer { suppressAutoHide = false } + // The bar's local monitor normally consumes typeable keys for search + // and navigation. Suspend it while the native editor owns first + // responder so typing, Delete, Return, and Writing Tools all reach + // the NSTextView instead. + let resumeBarKeys = barController?.window?.isVisible == true + if resumeBarKeys { stopKeyMonitor() } + defer { + suppressAutoHide = false + if resumeBarKeys { startKeyMonitor() } + } guard let edit = ClipEditor.run(for: item, launchWritingTools: launchWritingTools) else { return } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index b231928..37da27a 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -226,8 +226,8 @@ struct ClipCardView: View { Label { Text(b.name) } icon: { - Image(systemName: "circle.fill") - .foregroundStyle(b.color) + Image(nsImage: Self.pinboardMenuIcon(color: NSColor(b.color))) + .renderingMode(.original) } } } @@ -271,4 +271,18 @@ struct ClipCardView: View { guard #available(macOS 15.2, *) else { return false } return NSWritingToolsCoordinator.isWritingToolsAvailable } + + /// SwiftUI's symbol tint is converted to a template image when rendered in + /// an NSMenu. Draw the pinboard color into a non-template image instead so + /// the native submenu keeps the colored dots shown throughout Pesty. + private static func pinboardMenuIcon(color: NSColor) -> NSImage { + let size = NSSize(width: 12, height: 12) + let image = NSImage(size: size, flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill() + return true + } + image.isTemplate = false + return image + } } From 643e0a0f9b733913bcba7c4610160be5b2872b5f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:45:21 -0700 Subject: [PATCH 39/44] Keep context-menu commands scoped to their menu --- Sources/Pesty/AppController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index c350b09..e5c1929 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -135,6 +135,7 @@ final class AppController: NSObject, NSApplicationDelegate { let front = NSWorkspace.shared.frontmostApplication if let front, !isPesty(front) { previousApp = front + lastActiveApp = front } store.searchText = "" store.source = .history @@ -161,7 +162,7 @@ final class AppController: NSObject, NSApplicationDelegate { /// `previousApp` is captured before the panel activates, while /// `lastActiveApp` covers menu-bar and reopen paths where it is unavailable. private var pasteTarget: NSRunningApplication? { - [previousApp, lastActiveApp, NSWorkspace.shared.frontmostApplication] + [lastActiveApp, previousApp, NSWorkspace.shared.frontmostApplication] .compactMap { $0 } .first { !$0.isTerminated && !isPesty($0) } } @@ -300,6 +301,11 @@ final class AppController: NSObject, NSApplicationDelegate { } private func handleKey(_ event: NSEvent) -> NSEvent? { + // Events belonging to a native context menu, editor, or Settings + // window must stay with their own responder chain. The bar monitor is + // only responsible for keys delivered to the Paste Bar panel itself. + guard event.window === barController?.window else { return event } + let code = Int(event.keyCode) let flags = event.modifierFlags let cmd = flags.contains(.command) From 829a0154bd17612db4337785e420ef98699b4080 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:47:13 -0700 Subject: [PATCH 40/44] Scope bulk deletion to the active collection --- Sources/Pesty/Store/ClipboardStore.swift | 67 ++++++++++++++++++------ 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 842d018..7bcdeff 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -14,7 +14,14 @@ final class ClipboardStore { private(set) var history: [ClipItem] = [] private(set) var pinboards: [Pinboard] = [] - var source: BarSource = .history + var source: BarSource = .history { + didSet { + // A selection belongs to the visible strip, not to a clip UUID + // globally. Pinboard copies intentionally share their source + // item IDs, so carrying selection across sources is misleading. + if source != oldValue { selectFirst() } + } + } var searchText: String = "" var selectedID: UUID? /// Every selected clip in the current strip. `selectedID` remains the @@ -114,6 +121,7 @@ final class ClipboardStore { let removed = Array(history[historyLimit...]) history.removeLast(history.count - historyLimit) for item in removed { deleteImageFile(item) } + reconcileSelection() } func delete(_ item: ClipItem) { @@ -144,29 +152,32 @@ final class ClipboardStore { let ids = Set(items.map(\.id)) guard !ids.isEmpty else { return } - let removed = history.filter { ids.contains($0.id) } - + pinboards.flatMap { $0.items.filter { ids.contains($0.id) } } - history.removeAll { ids.contains($0.id) } - for i in pinboards.indices { pinboards[i].items.removeAll { ids.contains($0.id) } } - for item in removed { deleteImageFile(item) } - - if let selectedID, ids.contains(selectedID) { - selectFirst() - } else { - selectedIDs.subtract(ids) - if let selectionAnchorID, ids.contains(selectionAnchorID) { - self.selectionAnchorID = selectedID - } + // A Pinboard is an independently saved collection. Deleting from the + // current strip must not erase same-ID copies in other pinboards (or + // from history), even though copies retain their source clip ID. + let removed: [ClipItem] + switch source { + case .history: + removed = history.filter { ids.contains($0.id) } + history.removeAll { ids.contains($0.id) } + case .pinboard(let boardID): + guard let boardIndex = pinboards.firstIndex(where: { $0.id == boardID }) else { return } + removed = pinboards[boardIndex].items.filter { ids.contains($0.id) } + pinboards[boardIndex].items.removeAll { ids.contains($0.id) } } + for item in removed { deleteImageFile(item) } + reconcileSelection() scheduleSave() } func clearHistory() { let old = history history.removeAll() - selectedID = nil - selectedIDs = [] - selectionAnchorID = nil + if source == .history { + selectOnly(nil) + } else { + reconcileSelection() + } for item in old { deleteImageFile(item) } scheduleSave() } @@ -215,6 +226,28 @@ final class ClipboardStore { func selectFirst() { selectOnly(visibleItems.first?.id) } + /// Removes selections that are no longer in the current source or filter. + /// This is called after structural changes so selection count, highlights, + /// and bulk-delete always refer to the same visible cards. + private func reconcileSelection() { + let visibleIDs = Set(visibleItems.map(\.id)) + selectedIDs.formIntersection(visibleIDs) + + guard let selectedID, visibleIDs.contains(selectedID) else { + if let first = visibleItems.first?.id { + selectOnly(first) + } else { + selectOnly(nil) + } + return + } + + selectedIDs.insert(selectedID) + if let selectionAnchorID, !visibleIDs.contains(selectionAnchorID) { + self.selectionAnchorID = selectedID + } + } + func moveSelection(by delta: Int) { let items = visibleItems guard !items.isEmpty else { return } From d803e4460b0f1b0e15a70668cec45b0a89f0ddb0 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:53:43 -0700 Subject: [PATCH 41/44] Place Paste Stack drop target before pasted clips --- Sources/Pesty/UI/PasteStackView.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift index 545df4f..7e474a8 100644 --- a/Sources/Pesty/UI/PasteStackView.swift +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -87,7 +87,7 @@ struct PasteStackView: View { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 7) { - ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + ForEach(Array(stack.displayEntries.filter { !$0.isPasted }.enumerated()), id: \.element.id) { index, entry in PasteStackEntryRow(entry: entry, index: index + 1, selected: stack.selectedEntryID == entry.id, @@ -106,6 +106,12 @@ struct PasteStackView: View { isDropTargeted: $isDropTargetAtEnd ) } + ForEach(Array(stack.displayEntries.filter(\.isPasted).enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: stack.pendingCount + index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: false) + } } .padding(12) } @@ -379,7 +385,7 @@ struct PasteStackContentView: View { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 8) { - ForEach(Array(stack.displayEntries.enumerated()), id: \.element.id) { index, entry in + ForEach(Array(stack.displayEntries.filter { !$0.isPasted }.enumerated()), id: \.element.id) { index, entry in PasteStackEntryRow(entry: entry, index: index + 1, selected: stack.selectedEntryID == entry.id, @@ -398,6 +404,12 @@ struct PasteStackContentView: View { isDropTargeted: $isDropTargetAtEnd ) } + ForEach(Array(stack.displayEntries.filter(\.isPasted).enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + index: stack.pendingCount + index + 1, + selected: stack.selectedEntryID == entry.id, + showsPasteAction: true) + } } .padding(.horizontal, 24) .padding(.vertical, 16) From 2cc1c92d3a8ca8226b79d9ce3d2ad65e3fbba517 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 19:09:53 -0700 Subject: [PATCH 42/44] Add final compact inline clip preview --- Sources/Pesty/AppController.swift | 33 +++- Sources/Pesty/Models/ClipItem.swift | 20 ++ Sources/Pesty/Settings/Settings.swift | 8 +- Sources/Pesty/UI/BarView.swift | 54 ++++-- Sources/Pesty/UI/BarWindowController.swift | 9 +- Sources/Pesty/UI/ClipCardView.swift | 42 +++-- Sources/Pesty/UI/ClipPreviewView.swift | 30 +-- Sources/Pesty/UI/ClipPreviewViews.swift | 176 ++++++++++++++++-- .../UI/InlinePreviewWindowController.swift | 89 +++++++++ Sources/Pesty/Util/LinkPreviewStore.swift | 32 +++- Sources/Pesty/Util/QuickLookService.swift | 12 +- 11 files changed, 431 insertions(+), 74 deletions(-) create mode 100644 Sources/Pesty/UI/InlinePreviewWindowController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d38dbef..2beab84 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -16,6 +16,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var settingsWindow: NSWindow? private var pasteStackController: PasteStackWindowController? private var previewWindow: NSWindow? + private var inlinePreviewController: InlinePreviewWindowController? private var previewedItemID: UUID? private var keyMonitor: Any? private var isReopenPresentationPending = false @@ -209,6 +210,7 @@ final class AppController: NSObject, NSApplicationDelegate { store.applyHistoryPolicy() if store.source != .pasteStack { store.selectFirst() } store.inlinePreviewVisible = false + inlinePreviewController?.hide() if barController == nil { barController = BarWindowController() @@ -220,9 +222,36 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() store.inlinePreviewVisible = false + inlinePreviewController?.hide() barController?.hide() } + func toggleInlinePreview() { + guard Settings.shared.clipPreviewStyle == .inlinePesty, + store.source != .pasteStack, + store.selectedItem != nil else { return } + if store.inlinePreviewVisible { + hideInlinePreview() + } else { + store.inlinePreviewVisible = true + } + } + + func hideInlinePreview() { + store.inlinePreviewVisible = false + inlinePreviewController?.hide() + } + + func updateInlinePreview(item: ClipItem, cardFrame: CGRect) { + guard store.inlinePreviewVisible, + let barWindow = barController?.window, + barWindow.isVisible else { return } + if inlinePreviewController == nil { + inlinePreviewController = InlinePreviewWindowController() + } + inlinePreviewController?.show(item: item, anchoredTo: cardFrame, in: barWindow) + } + func resizeVisibleBar(to height: Double) { barController?.resize(to: CGFloat(height)) } @@ -574,8 +603,8 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_Space where store.searchText.isEmpty: if Settings.shared.clipPreviewStyle == .nativeQuickLook { QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) - } else if store.selectedItem != nil { - store.inlinePreviewVisible.toggle() + } else { + toggleInlinePreview() } return nil case kVK_Escape: diff --git a/Sources/Pesty/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index 7111ca2..6081e97 100644 --- a/Sources/Pesty/Models/ClipItem.swift +++ b/Sources/Pesty/Models/ClipItem.swift @@ -1,4 +1,5 @@ import AppKit +import UniformTypeIdentifiers struct ClipItem: Identifiable, Codable, Equatable { let id: UUID @@ -44,6 +45,25 @@ struct ClipItem: Identifiable, Codable, Equatable { var charCount: Int { text?.count ?? 0 } + /// Finder image files remain file clips for copy/paste purposes, but are + /// presented as images throughout Pesty so their thumbnail is useful. + var imageFileURL: URL? { + guard type == .file, + fileURLs.count == 1, + let value = fileURLs.first, + let url = URL(string: value), + url.isFileURL, + let fileType = UTType(filenameExtension: url.pathExtension), + fileType.conforms(to: .image) else { return nil } + return url + } + + var isImageFile: Bool { imageFileURL != nil } + + /// UI-only classification. Keep `type == .file` so pasting and dragging a + /// Finder image preserves its file representation. + var presentationType: ClipType { isImageFile ? .image : type } + /// The representation used when a clip is explicitly pasted as plain text. /// Images intentionally do not have one: converting an image to an /// arbitrary description would be surprising and lossy. diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index d338735..3e21ae2 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -358,7 +358,13 @@ final class Settings { } var clipPreviewStyle: ClipPreviewStyle { - didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } + didSet { + guard isLoaded else { return } + d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) + if clipPreviewStyle != .inlinePesty { + ClipboardStore.shared.inlinePreviewVisible = false + } + } } var selectedClipPosition: SelectedClipPosition { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 18aeb05..2d086f8 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -12,6 +12,7 @@ struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared @State private var resizeStartHeight: Double? + @State private var cardFrames: [UUID: CGRect] = [:] private var sequence: PasteSequence { AppController.shared.pasteSequence } private var showsStackDeck: Bool { @@ -22,25 +23,29 @@ struct BarView: View { ZStack { VisualEffectView(material: .hudWindow) Theme.panelTint - } - .overlay(alignment: .top) { VStack(spacing: 0) { if settings.showBarResizeHandle { resizeHandle } topBar if store.source == .pasteStack { PasteStackContentView() } else { - HStack(spacing: 0) { - if settings.clipPreviewStyle == .inlinePesty, - store.inlinePreviewVisible, - let item = store.selectedItem { - SelectedClipPreviewView(item: item) - Divider() - } - strip - } + strip } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .coordinateSpace(name: "PestyBar") + .onPreferenceChange(ClipCardFramePreferenceKey.self) { + cardFrames = $0 + updateFloatingPreview() + } + .onChange(of: store.inlinePreviewVisible) { _, visible in + guard visible else { return } + DispatchQueue.main.async { updateFloatingPreview() } + } + .onChange(of: store.selectedID) { _, _ in + guard store.inlinePreviewVisible else { return } + DispatchQueue.main.async { updateFloatingPreview() } } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() @@ -95,7 +100,7 @@ struct BarView: View { } private var previewButton: some View { - Button { store.inlinePreviewVisible.toggle() } label: { + Button { AppController.shared.toggleInlinePreview() } label: { Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) @@ -105,6 +110,15 @@ struct BarView: View { .help(store.inlinePreviewVisible ? "Hide clip preview" : "Show clip preview") } + private func updateFloatingPreview() { + guard settings.clipPreviewStyle == .inlinePesty, + store.source != .pasteStack, + store.inlinePreviewVisible, + let item = store.selectedItem, + let frame = cardFrames[item.id] else { return } + AppController.shared.updateInlinePreview(item: item, cardFrame: frame) + } + private func searchWidth(in barWidth: CGFloat) -> CGFloat { guard !store.searchText.isEmpty else { return Self.collapsedSearchWidth } let available = barWidth - (2 * 18) - Self.minimumNonSearchChromeWidth @@ -226,6 +240,14 @@ struct BarView: View { selected: store.selectedIDs.contains(item.id)) .frame(height: cardHeight) .id(item.id) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: ClipCardFramePreferenceKey.self, + value: [item.id: proxy.frame(in: .named("PestyBar"))] + ) + } + } .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), removal: .opacity)) @@ -274,6 +296,14 @@ struct BarView: View { } } +private struct ClipCardFramePreferenceKey: PreferenceKey { + static var defaultValue: [UUID: CGRect] = [:] + + static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + struct RoundedCorners: Shape { var radius: CGFloat var corners: RectCorner diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 365ffce..97cbffb 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -91,11 +91,12 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { guard Settings.shared.hideOnClickOutside, !isPresenting, !AppController.shared.suppressAutoHide else { return } - // Quick Look becomes key immediately after the strip hands it a preview. - // Defer until that transition is visible before deciding whether focus - // actually left Pesty. + // A preview panel becomes key immediately after the strip hands it a + // clip. Defer until that transition is visible before deciding whether + // focus actually left Pesty. DispatchQueue.main.async { - guard !QuickLookService.shared.isVisible else { return } + guard !QuickLookService.shared.isVisible, + !ClipboardStore.shared.inlinePreviewVisible else { return } AppController.shared.hideBar() } } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index aa90395..bab35bb 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -10,6 +10,11 @@ struct ClipCardView: View { private var store: ClipboardStore { ClipboardStore.shared } private var settings: Settings { Settings.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } + private var presentationType: ClipType { item.presentationType } + private var imageFile: NSImage? { + guard let url = item.imageFileURL else { return nil } + return NSImage(contentsOf: url) + } var body: some View { VStack(spacing: 0) { @@ -40,7 +45,7 @@ struct ClipCardView: View { headerColor HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 2) { - Text(item.type.label) + Text(presentationType.label) .font(.system(size: 14, weight: .bold)) .foregroundStyle(Theme.headerText) Text(item.createdAt.clipRelativeLong) @@ -99,22 +104,29 @@ struct ClipCardView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) case .file: - VStack(spacing: 9) { - Image(systemName: "doc.fill").font(.system(size: 32)) - .foregroundStyle(headerColor) - Text(item.displayTitle).font(.system(size: 12)) - .foregroundStyle(Theme.textSecondary).lineLimit(2) - .multilineTextAlignment(.center) + Group { + if let imageFile { + VStack(spacing: 8) { + Image(nsImage: imageFile) + .resizable().interpolation(.high).scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + Text(item.displayTitle).font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary).lineLimit(1) + } + } else { + VStack(spacing: 9) { + Image(systemName: "doc.fill").font(.system(size: 32)) + .foregroundStyle(headerColor) + Text(item.displayTitle).font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary).lineLimit(2) + .multilineTextAlignment(.center) + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) case .link: - VStack(spacing: 10) { - Spacer(minLength: 0) - Image(systemName: "safari").font(.system(size: 34, weight: .light)) - .foregroundStyle(Theme.textTertiary) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) + LinkCardPreview(text: item.text ?? item.displayTitle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) default: Text(item.text ?? "") .font(.system(size: 12.5)) @@ -165,7 +177,7 @@ struct ClipCardView: View { return (item.text ?? "").replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "") case .file: - return "\(item.fileURLs.count) file\(item.fileURLs.count == 1 ? "" : "s")" + return item.isImageFile ? "Image" : "\(item.fileURLs.count) file\(item.fileURLs.count == 1 ? "" : "s")" case .image: return "Image" case .color: diff --git a/Sources/Pesty/UI/ClipPreviewView.swift b/Sources/Pesty/UI/ClipPreviewView.swift index dbfeb79..86e850b 100644 --- a/Sources/Pesty/UI/ClipPreviewView.swift +++ b/Sources/Pesty/UI/ClipPreviewView.swift @@ -10,15 +10,15 @@ struct ClipPreviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 10) { - Image(systemName: item.type.symbol) + Image(systemName: item.presentationType.symbol) .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(item.type.accent) + .foregroundStyle(item.presentationType.accent) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(item.displayTitle) .font(.headline) .lineLimit(2) - Text(item.type.label) + Text(item.presentationType.label) .font(.subheadline) .foregroundStyle(.secondary) } @@ -59,14 +59,22 @@ struct ClipPreviewView: View { .textSelection(.enabled) } case .file: - VStack(alignment: .leading, spacing: 10) { - ForEach(item.fileURLs, id: \.self) { value in - let url = URL(string: value) - HStack(alignment: .top, spacing: 9) { - Image(systemName: "doc") - .foregroundStyle(.secondary) - Text(url?.path ?? value) - .textSelection(.enabled) + if let url = item.imageFileURL, let image = NSImage(contentsOf: url) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(item.fileURLs, id: \.self) { value in + let url = URL(string: value) + HStack(alignment: .top, spacing: 9) { + Image(systemName: "doc") + .foregroundStyle(.secondary) + Text(url?.path ?? value) + .textSelection(.enabled) + } } } } diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 438c16f..974c98b 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import WebKit struct RichTextContent: View { let rtfData: Data? @@ -78,34 +79,180 @@ struct LinkPreviewContent: View { } } +struct LinkCardPreview: View { + let text: String + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Group { + if let image = preview?.image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFill() + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .overlay { + Image(systemName: "link") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 104) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack(spacing: 7) { + if let icon = preview?.icon { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } else { + Image(systemName: "link") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16, height: 16) + } + Text(preview?.title ?? host) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + } + } + .onAppear { previews.load(for: url) } + } +} + +struct PestyPreviewPopover: View { + let item: ClipItem + let pointerOffset: CGFloat + + private var url: URL? { + guard item.type == .link else { return nil } + return URL(string: (item.text ?? item.displayTitle).trimmingCharacters(in: .whitespacesAndNewlines)) + } + + var body: some View { + VStack(spacing: 0) { + previewPanel + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.22)) + } + PreviewPointer() + .fill(Color(nsColor: .windowBackgroundColor)) + .frame(width: 22, height: 11) + .offset(x: pointerOffset) + } + .padding(8) + .shadow(color: .black.opacity(0.32), radius: 18, y: 8) + .allowsHitTesting(true) + } + + private var previewPanel: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { AppController.shared.hideInlinePreview() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Text(item.presentationType.label) + .font(.system(size: 17, weight: .bold)) + Spacer() + if let url { + Button("Open in Safari") { NSWorkspace.shared.open(url) } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + + Divider() + + Group { + if let url { + WebLinkPreview(url: url) + } else { + SelectedClipPreviewView(item: item) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .padding(12) + } + } +} + +private struct WebLinkPreview: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.load(URLRequest(url: url)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard webView.url != url else { return } + webView.load(URLRequest(url: url)) + } +} + +private struct PreviewPointer: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + struct SelectedClipPreviewView: View { let item: ClipItem private var store: ClipboardStore { ClipboardStore.shared } + private var primaryText: Color { Color(nsColor: .labelColor) } + private var secondaryText: Color { Color(nsColor: .secondaryLabelColor) } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { - Image(systemName: item.type.symbol) - .foregroundStyle(item.type.accent) - Text(item.type.label) + Image(systemName: item.presentationType.symbol) + .foregroundStyle(item.presentationType.accent) + Text(item.presentationType.label) .font(.system(size: 12, weight: .bold)) - .foregroundStyle(Theme.textSecondary) + .foregroundStyle(secondaryText) Spacer() Text(item.createdAt.clipRelativeLong) .font(.system(size: 11)) - .foregroundStyle(Theme.textTertiary) + .foregroundStyle(secondaryText) } previewContent .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) Text(item.displayTitle) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.textSecondary) + .foregroundStyle(secondaryText) .lineLimit(2) } .padding(16) - .frame(width: 340) .frame(maxHeight: .infinity, alignment: .topLeading) - .background(Color.white.opacity(0.58)) + .background(Color(nsColor: .textBackgroundColor)) } @ViewBuilder @@ -122,7 +269,7 @@ struct SelectedClipPreviewView: View { case .richText: ScrollView { RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", font: .system(size: 15)) - .foregroundStyle(Theme.textPrimary) + .foregroundStyle(primaryText) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } @@ -131,7 +278,7 @@ struct SelectedClipPreviewView: View { LinkPreviewContent(text: item.text ?? item.displayTitle, compact: false) Text(item.text ?? "") .font(.system(size: 12)) - .foregroundStyle(Theme.textSecondary) + .foregroundStyle(secondaryText) .textSelection(.enabled) .lineLimit(3) Spacer() @@ -151,7 +298,7 @@ struct SelectedClipPreviewView: View { Text(item.displayTitle) .font(.system(size: 14, weight: .medium)) .multilineTextAlignment(.center) - .foregroundStyle(Theme.textPrimary) + .foregroundStyle(primaryText) Spacer() } .frame(maxWidth: .infinity) @@ -169,7 +316,7 @@ struct SelectedClipPreviewView: View { ScrollView { Text(item.text ?? "") .font(.system(size: 15)) - .foregroundStyle(Theme.textPrimary) + .foregroundStyle(primaryText) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } @@ -184,10 +331,7 @@ struct SelectedClipPreviewView: View { } private var filePreviewImage: NSImage? { - guard item.fileURLs.count == 1, - let value = item.fileURLs.first, - let url = URL(string: value), - url.isFileURL else { return nil } + guard let url = item.imageFileURL else { return nil } return NSImage(contentsOf: url) } } diff --git a/Sources/Pesty/UI/InlinePreviewWindowController.swift b/Sources/Pesty/UI/InlinePreviewWindowController.swift new file mode 100644 index 0000000..e4caa26 --- /dev/null +++ b/Sources/Pesty/UI/InlinePreviewWindowController.swift @@ -0,0 +1,89 @@ +import AppKit +import SwiftUI + +private final class InlinePreviewPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +/// A compact, non-modal preview anchored above the selected clip. It leaves the +/// Paste Bar itself unchanged so opening a preview never turns the whole bar +/// into a sheet. +@MainActor +final class InlinePreviewWindowController: NSWindowController { + private let hostingView: NSHostingView + private var currentItemID: UUID? + private var currentPointerOffset: CGFloat = 0 + + init() { + let host = NSHostingView(rootView: AnyView(EmptyView())) + hostingView = host + + let panel = InlinePreviewPanel( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 340), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = NSWindow.Level(rawValue: NSWindow.Level.modalPanel.rawValue + 1) + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.isMovable = false + panel.contentView = host + + super.init(window: panel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + func show(item: ClipItem, anchoredTo cardFrame: CGRect, in barWindow: NSWindow) { + guard let panel = window, + let screen = barWindow.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(barWindow.frame) }) + ?? NSScreen.main else { return } + + let cardOnScreen = NSRect( + x: barWindow.frame.minX + cardFrame.minX, + y: barWindow.frame.minY + barWindow.frame.height - cardFrame.maxY, + width: cardFrame.width, + height: cardFrame.height + ) + let presentation = presentationFrame(for: cardOnScreen, on: screen) + + if !panel.isVisible || currentItemID != item.id || abs(currentPointerOffset - presentation.pointerOffset) > 1 { + hostingView.rootView = AnyView( + PestyPreviewPopover(item: item, pointerOffset: presentation.pointerOffset) + ) + currentItemID = item.id + currentPointerOffset = presentation.pointerOffset + } + + panel.setFrame(presentation.frame, display: true) + panel.makeKeyAndOrderFront(nil) + } + + func hide() { + window?.orderOut(nil) + } + + private func presentationFrame(for card: NSRect, on screen: NSScreen) -> (frame: NSRect, pointerOffset: CGFloat) { + let visible = screen.visibleFrame + let horizontalInset: CGFloat = 20 + let width = min(540, max(360, visible.width - horizontalInset * 2)) + let minX = visible.minX + horizontalInset + let maxX = visible.maxX - horizontalInset - width + let x = min(max(minX, card.midX - width / 2), maxX) + + let arrowTipY = card.maxY - 2 + let availableHeight = visible.maxY - arrowTipY - 20 + let height = min(420, max(260, availableHeight)) + let pointerLimit = max(0, width / 2 - 34) + let pointerOffset = min(pointerLimit, max(-pointerLimit, card.midX - (x + width / 2))) + + return (NSRect(x: x, y: arrowTipY, width: width, height: height), pointerOffset) + } +} diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift index 4702bec..c617058 100644 --- a/Sources/Pesty/Util/LinkPreviewStore.swift +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -5,6 +5,7 @@ import Observation struct LinkPreview { var title: String? var icon: NSImage? + var image: NSImage? } @Observable @@ -34,9 +35,17 @@ final class LinkPreviewStore { request.timeoutInterval = 5 request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { data, _, _ in - let title = data.flatMap(Self.pageTitle(from:)) + let metadata = data.flatMap { Self.pageMetadata(from: $0, relativeTo: url) } DispatchQueue.main.async { - self.update(host: host, title: title, icon: nil, finished: false) + self.update(host: host, title: metadata?.title, icon: nil, image: nil, finished: false) + } + if let imageURL = metadata?.imageURL { + URLSession.shared.dataTask(with: imageURL) { imageData, _, _ in + let image = imageData.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: nil, image: image, finished: false) + } + }.resume() } }.resume() @@ -51,27 +60,36 @@ final class LinkPreviewStore { URLSession.shared.dataTask(with: iconURL) { data, _, _ in let icon = data.flatMap(NSImage.init(data:)) DispatchQueue.main.async { - self.update(host: host, title: nil, icon: icon, finished: true) + self.update(host: host, title: nil, icon: icon, image: nil, finished: true) } }.resume() } - private func update(host: String, title: String?, icon: NSImage?, finished: Bool) { + private func update(host: String, title: String?, icon: NSImage?, image: NSImage?, finished: Bool) { var preview = previews[host] ?? LinkPreview() if let title, !title.isEmpty { preview.title = title } if let icon { preview.icon = icon } + if let image { preview.image = image } previews[host] = preview if finished { loadingHosts.remove(host) } } - nonisolated private static func pageTitle(from data: Data) -> String? { + nonisolated private static func pageMetadata(from data: Data, relativeTo url: URL) -> (title: String?, imageURL: URL?)? { guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), let expression = try? NSRegularExpression(pattern: "]*>(.*?)", - options: [.caseInsensitive, .dotMatchesLineSeparators]), + options: [.caseInsensitive, .dotMatchesLineSeparators]), let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), let range = Range(match.range(at: 1), in: html) else { return nil } - return html[range] + let title = html[range] .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) + let imagePattern = "]+(?:property|name)=[\\\"'](?:og:image|twitter:image)[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']" + let imageExpression = try? NSRegularExpression(pattern: imagePattern, options: [.caseInsensitive]) + let imageURL: URL? = imageExpression.flatMap { expression in + guard let imageMatch = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let imageRange = Range(imageMatch.range(at: 1), in: html) else { return nil } + return URL(string: String(html[imageRange]), relativeTo: url)?.absoluteURL + } + return (title.isEmpty ? nil : title, imageURL) } } diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift index 0479013..0d8632f 100644 --- a/Sources/Pesty/Util/QuickLookService.swift +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -57,14 +57,14 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource switch clip.type { case .file: let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) - if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.type.label) } } + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.presentationType.label) } } case .image: if let url = ClipboardStore.shared.imageURL(for: clip) { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .richText: if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .color: let hex = clip.colorHex ?? "#000000" @@ -75,18 +75,18 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource case .text: let text = clip.text ?? clip.displayTitle if let url = write(Data(textPreviewHTML(for: text).utf8), named: "Text", extension: "html") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .link: let text = clip.text ?? clip.displayTitle if let url = write(Data(textPreviewHTML(for: text, isLink: true).utf8), named: "Link", extension: "html") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } } let text = clip.text ?? clip.displayTitle guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } private func prepareTemporaryDirectory() { From ac0dfb3fbf67b371398ef0b0b8e190bd700250ea Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 19:10:16 -0700 Subject: [PATCH 43/44] Keep inline preview navigation in the Paste Bar --- Sources/Pesty/UI/ClipPreviewViews.swift | 1 - Sources/Pesty/UI/InlinePreviewWindowController.swift | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 974c98b..31e97b7 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -156,7 +156,6 @@ struct PestyPreviewPopover: View { .offset(x: pointerOffset) } .padding(8) - .shadow(color: .black.opacity(0.32), radius: 18, y: 8) .allowsHitTesting(true) } diff --git a/Sources/Pesty/UI/InlinePreviewWindowController.swift b/Sources/Pesty/UI/InlinePreviewWindowController.swift index e4caa26..7c994fa 100644 --- a/Sources/Pesty/UI/InlinePreviewWindowController.swift +++ b/Sources/Pesty/UI/InlinePreviewWindowController.swift @@ -2,7 +2,9 @@ import AppKit import SwiftUI private final class InlinePreviewPanel: NSPanel { - override var canBecomeKey: Bool { true } + // Keep keyboard focus in the Paste Bar. The preview remains clickable, but + // Left/Right and other bar shortcuts continue to work while it is visible. + override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } @@ -63,7 +65,11 @@ final class InlinePreviewWindowController: NSWindowController { } panel.setFrame(presentation.frame, display: true) - panel.makeKeyAndOrderFront(nil) + // A non-key panel otherwise can remain behind the key Paste Bar. + // Ordering it regardless keeps the preview visible without stealing + // the arrows and other keyboard controls. + panel.orderFrontRegardless() + barWindow.makeKey() } func hide() { From d56732bff863d062eb16a84cdf80401a5ca86d56 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 19:10:44 -0700 Subject: [PATCH 44/44] Open preview content in native apps --- Sources/Pesty/UI/ClipPreviewViews.swift | 4 +- .../Util/InlinePreviewExternalOpener.swift | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 Sources/Pesty/Util/InlinePreviewExternalOpener.swift diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 31e97b7..71e1b1d 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -171,8 +171,8 @@ struct PestyPreviewPopover: View { Text(item.presentationType.label) .font(.system(size: 17, weight: .bold)) Spacer() - if let url { - Button("Open in Safari") { NSWorkspace.shared.open(url) } + if let externalActionTitle = InlinePreviewExternalOpener.actionTitle(for: item) { + Button(externalActionTitle) { InlinePreviewExternalOpener.open(item) } .buttonStyle(.bordered) } } diff --git a/Sources/Pesty/Util/InlinePreviewExternalOpener.swift b/Sources/Pesty/Util/InlinePreviewExternalOpener.swift new file mode 100644 index 0000000..e0319af --- /dev/null +++ b/Sources/Pesty/Util/InlinePreviewExternalOpener.swift @@ -0,0 +1,96 @@ +import AppKit + +/// Opens an inline-preview clip in the appropriate native macOS app without +/// exposing Pesty's managed history files to edits made in that app. +@MainActor +enum InlinePreviewExternalOpener { + private static let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("Pesty-External-Previews", isDirectory: true) + + static func actionTitle(for item: ClipItem) -> String? { + switch item.type { + case .link: + return linkURL(for: item) == nil ? nil : "Open in Default Browser" + case .image: + return "Open in Preview" + case .file where item.isImageFile: + return "Open in Preview" + case .text, .richText: + return "Open in TextEdit" + case .color, .file: + return nil + } + } + + static func open(_ item: ClipItem) { + switch item.type { + case .link: + guard let url = linkURL(for: item) else { return } + NSWorkspace.shared.open(url) + case .image: + guard let source = ClipboardStore.shared.imageURL(for: item), + let copy = copyToTemporaryLocation(source, named: item.displayTitle) else { return } + open(copy, withApplicationBundleID: "com.apple.Preview") + case .file where item.isImageFile: + guard let source = item.imageFileURL, + let copy = copyToTemporaryLocation(source, named: item.displayTitle) else { return } + open(copy, withApplicationBundleID: "com.apple.Preview") + case .richText: + guard let data = item.rtfData, + let url = write(data, named: item.displayTitle, fileExtension: "rtf") else { return } + open(url, withApplicationBundleID: "com.apple.TextEdit") + case .text: + let data = Data((item.text ?? "").utf8) + guard let url = write(data, named: item.displayTitle, fileExtension: "txt") else { return } + open(url, withApplicationBundleID: "com.apple.TextEdit") + case .color, .file: + return + } + } + + private static func linkURL(for item: ClipItem) -> URL? { + guard let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: text) else { return nil } + return url + } + + private static func copyToTemporaryLocation(_ source: URL, named title: String) -> URL? { + guard let data = try? Data(contentsOf: source) else { return nil } + let fileExtension = source.pathExtension.isEmpty ? "png" : source.pathExtension + return write(data, named: title, fileExtension: fileExtension) + } + + private static func write(_ data: Data, named title: String, fileExtension: String) -> URL? { + let fm = FileManager.default + do { + try fm.createDirectory(at: temporaryDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } catch { + return nil + } + + let safeTitle = title + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ":", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseName = safeTitle.isEmpty ? "Clip" : String(safeTitle.prefix(80)) + let url = temporaryDirectory + .appendingPathComponent("\(baseName)-\(UUID().uuidString)") + .appendingPathExtension(fileExtension) + do { + try data.write(to: url, options: .atomic) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return url + } catch { + return nil + } + } + + private static func open(_ url: URL, withApplicationBundleID bundleID: String) { + guard let applicationURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { return } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.open([url], withApplicationAt: applicationURL, configuration: configuration) + } +}