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 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..2beab84 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -8,14 +8,22 @@ 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 pauseMenuItem: NSMenuItem? 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 private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? + private var pasteStackTargetApp: NSRunningApplication? var suppressAutoHide = false @@ -29,9 +37,10 @@ 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() + setMenuBarIconVisible(Settings.shared.showMenuBarIcon) if Settings.shared.launchAtLogin { LaunchAtLogin.set(enabled: true) } @@ -51,10 +60,43 @@ 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 { 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() + } } } @@ -63,16 +105,17 @@ 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") - 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: "") @@ -82,12 +125,35 @@ 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() } + @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: [ @@ -129,14 +195,22 @@ final class AppController: NSObject, NSApplicationDelegate { } } - func showBar() { + func showBar(source requestedSource: BarSource? = nil) { let front = NSWorkspace.shared.frontmostApplication - if front?.bundleIdentifier != Bundle.main.bundleIdentifier { + if let front, !isPesty(front) { previousApp = front + lastActiveApp = 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 - store.selectFirst() + store.source = requestedSource ?? .history + store.applyHistoryPolicy() + if store.source != .pasteStack { store.selectFirst() } + store.inlinePreviewVisible = false + inlinePreviewController?.hide() if barController == nil { barController = BarWindowController() @@ -147,18 +221,67 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() + store.inlinePreviewVisible = false + inlinePreviewController?.hide() barController?.hide() } - func pasteSelected() { + 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)) + } + + func pasteSelected(asPlainText: Bool = false) { guard let item = store.selectedItem else { return } - hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + pasteItem(item, asPlainText: asPlainText) + } + + /// 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? { + [lastActiveApp, previousApp, 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 +290,239 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func beginPasteSequence() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.begin() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func showPasteStack() { + if pasteStackController == nil { + pasteStackController = PasteStackWindowController() + } + pasteStackController?.show() + } + + func hidePasteStack() { + pasteStackController?.hide() + } + + func showPasteStackTab(stackID: UUID? = nil) { + store.searchText = "" + store.source = .pasteStack + if let stackID { + pasteSequence.selectStack(stackID) + } else { + pasteSequence.selectFirst() + } + pasteStackController?.hide() + if barController?.window?.isVisible != true { + showBar(source: .pasteStack) + } + } + + func cancelPasteSequence() { + pasteSequence.cancel() + pasteStackController?.hide() + pasteStackTargetApp = nil + } + + func newPasteStack() { + pasteStackTargetApp = previousApp ?? lastActiveApp + pasteSequence.newStack() + showPasteStack() + hideBar() + + let target = pasteStackTargetApp + DispatchQueue.main.async { + target?.activate(options: []) + } + } + + func pausePasteSequence() { + pasteSequence.pause() + } + + func clearPasteStack() { + cancelPasteSequence() + } + + func capturePasteStackItem(_ item: ClipItem) { + _ = pasteSequence.addIfNeeded(item) + } + + /// 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) + } + + func reAddPasteStackEntry(_ entry: PasteStackEntry) { + pasteSequence.reAdd(entry) + } + + func resetPasteStackProgress() { + pasteSequence.resetProgress() + } + + /// Saves the deck in its displayed paste order so a temporary Paste Stack + /// can become a durable Pinboard. + func savePasteStack() { + guard pasteSequence.hasEntries, + let name = TextPrompt.run(title: "Save Paste Stack", + message: "Save the current stack as a pinboard named:", + defaultValue: "Paste Stack") else { return } + + let board = store.addPinboard(name: name) + for entry in pasteSequence.displayEntries.reversed() { + store.saveToPinboard(entry.item, boardID: board.id) + } + } + + func pasteNextInSequence() { + #if !MAS + guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return } + #endif + + guard let entry = pasteSequence.next() else { return } + performPasteStackEntry(entry) + } + + func pasteStackEntry(_ entry: PasteStackEntry) { + guard let entry = pasteSequence.next(entryID: entry.id) else { return } + performPasteStackEntry(entry) + } + + func pasteSelectedStackEntry() { + guard let entry = pasteSequence.selectedEntry else { return } + pasteStackEntry(entry) + } + + private func performPasteStackEntry(_ entry: PasteStackEntry) { + let target = pasteTargetApp() + hideBar() + PasteService.paste(entry.item, + into: target, + monitor: monitor, + imageOverride: entry.imagePreview) + } + + /// Resolve the destination when the user chooses to paste, rather than + /// holding the app that was active when the Stack was first opened. + private func pasteTargetApp() -> NSRunningApplication? { + if let frontmost = NSWorkspace.shared.frontmostApplication, + !isPesty(frontmost) { + previousApp = frontmost + return frontmost + } + if let lastActiveApp, + !isPesty(lastActiveApp), + !lastActiveApp.isTerminated { + return lastActiveApp + } + return pasteStackTargetApp ?? previousApp + } + + func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { + suppressAutoHide = true + // 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 } + + 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 + 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 { @@ -178,13 +534,34 @@ 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: 760, height: 680)) win.center() win.isReleasedWhenClosed = false settingsWindow = win 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,43 +575,83 @@ 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 } + if handleBarCommandShortcut(event) { return nil } + let code = Int(event.keyCode) let flags = event.modifierFlags let cmd = flags.contains(.command) 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, + 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 } switch code { + case kVK_Space where store.searchText.isEmpty: + if Settings.shared.clipPreviewStyle == .nativeQuickLook { + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + } else { + toggleInlinePreview() + } + return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } 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: - store.moveSelection(by: -1); return nil + if store.source == .pasteStack { + pasteSequence.moveSelection(by: -1) + return nil + } + moveBarSelection(by: -1); return nil case kVK_RightArrow, kVK_DownArrow: - store.moveSelection(by: 1); return nil + if store.source == .pasteStack { + pasteSequence.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.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + 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) } + if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { + removePasteStackEntry(entry) + return nil + } + store.deleteSelected() 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 { @@ -244,6 +661,21 @@ final class AppController: NSObject, NSApplicationDelegate { } return event } + + private func moveBarSelection(by delta: Int) { + store.moveSelection(by: delta) + QuickLookService.shared.updateSelection(selectedID: store.selectedID) + } + + 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/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/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index b3689df..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,43 @@ 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. + 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/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..b226830 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,13 +25,17 @@ 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) + let storedItem = ClipboardStore.shared.addCaptured(item) + AppController.shared.capturePasteStackItem(storedItem) } private func makeItem() -> ClipItem? { @@ -50,6 +55,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/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..aed62c0 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,9 +5,18 @@ 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, + imageOverride: NSImage? = nil) -> 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 { + guard let img = imageOverride ?? ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount } pasteboard.clearContents() @@ -38,8 +47,10 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + asPlainText: Bool = false, + imageOverride: NSImage? = nil) { + let change = copy(item, asPlainText: asPlainText, 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..3e21ae2 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,222 @@ 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." + } + } +} + +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 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 + 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" + } + } +} + +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 { @@ -12,13 +228,28 @@ final class Settings { enum Keys { static let historyLimit = "historyLimit" + static let historyRetentionMode = "historyRetentionMode" + static let historyRetention = "historyRetention" + static let pasteStacksFollowHistory = "pasteStacksFollowHistory" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" + static let quickPasteModifier = "quickPasteModifier" + static let plainTextModifier = "plainTextModifier" + static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode" + static let sequenceHotkeyModifiers = "sequenceHotkeyModifiers" static let launchAtLogin = "launchAtLogin" + static let hideOnClickOutside = "hideOnClickOutside" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" + static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" + static let clipPreviewStyle = "clipPreviewStyle" + static let selectedClipPosition = "selectedClipPosition" + static let clipColorTheme = "clipColorTheme" + static let clipColorAccentHex = "clipColorAccentHex" + static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -28,10 +259,34 @@ final class Settings { guard isLoaded else { return } if historyLimit < 20 { historyLimit = 20; return } d.set(historyLimit, forKey: Keys.historyLimit) - ClipboardStore.shared.applyHistoryLimit() + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetentionMode: HistoryRetentionMode { + didSet { + guard isLoaded else { return } + d.set(historyRetentionMode.rawValue, forKey: Keys.historyRetentionMode) + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetention: HistoryRetention { + didSet { + guard isLoaded else { return } + d.set(historyRetention.rawValue, forKey: Keys.historyRetention) + if historyRetentionMode == .timePeriod { + ClipboardStore.shared.applyHistoryPolicy() + } } } + /// When enabled, removing clipboard history also removes those clips from + /// saved Paste Stacks. The default keeps stacks until the user deletes them. + var pasteStacksFollowHistory: Bool { + didSet { guard isLoaded else { return }; d.set(pasteStacksFollowHistory, forKey: Keys.pasteStacksFollowHistory) } + } + var hotkeyKeyCode: Int { didSet { guard isLoaded else { return } d.set(hotkeyKeyCode, forKey: Keys.hotkeyKeyCode); HotKeyCenter.shared.reload() } @@ -42,11 +297,33 @@ 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 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) } } + 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) } } @@ -59,12 +336,54 @@ 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 } let clamped = min(720, max(240, barHeight)) if clamped != barHeight { barHeight = clamped; return } d.set(barHeight, forKey: Keys.barHeight) + AppController.shared.resizeVisibleBar(to: barHeight) + } + } + + var showBarResizeHandle: Bool { + didSet { guard isLoaded else { return }; d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) } + } + + var clipPreviewStyle: ClipPreviewStyle { + didSet { + guard isLoaded else { return } + d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) + if clipPreviewStyle != .inlinePesty { + ClipboardStore.shared.inlinePreviewVisible = false + } + } + } + + var selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + + 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 } + d.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon) + AppController.shared.setMenuBarIconVisible(showMenuBarIcon) } } @@ -79,24 +398,55 @@ final class Settings { private init() { d.register(defaults: [ Keys.historyLimit: 500, + Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, + Keys.historyRetention: HistoryRetention.month.rawValue, + Keys.pasteStacksFollowHistory: false, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, + Keys.quickPasteModifier: ShortcutModifier.command.carbonValue, + Keys.plainTextModifier: ShortcutModifier.shift.carbonValue, + Keys.sequenceHotkeyKeyCode: kVK_ANSI_V, + Keys.sequenceHotkeyModifiers: cmdKey | optionKey, Keys.launchAtLogin: false, + Keys.hideOnClickOutside: true, Keys.pasteDirectly: true, Keys.playSound: false, Keys.ignoreConcealed: true, + Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, + Keys.showBarResizeHandle: false, + Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue, + Keys.clipColorTheme: ClipColorTheme.default.rawValue, + Keys.clipColorAccentHex: "#FF5A9F", + Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false ]) historyLimit = d.integer(forKey: Keys.historyLimit) + historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount + historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month + pasteStacksFollowHistory = d.bool(forKey: Keys.pasteStacksFollowHistory) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) + quickPasteModifier = d.integer(forKey: Keys.quickPasteModifier) + plainTextModifier = d.integer(forKey: Keys.plainTextModifier) + sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode) + sequenceHotkeyModifiers = d.integer(forKey: Keys.sequenceHotkeyModifiers) 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) + ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) + .filter { !$0.isEmpty } 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 + 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) isLoaded = true @@ -105,4 +455,27 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + var quickPasteModifierDisplay: String { + ShortcutModifier(carbonValue: quickPasteModifier)?.symbol ?? "⌘" + } + + var sequenceHotkeyDisplay: String { + HotKeyCenter.describe(keyCode: sequenceHotkeyKeyCode, modifiers: sequenceHotkeyModifiers) + } + + 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..87f3ec7 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -1,20 +1,138 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers struct SettingsView: View { + @State private var section: SettingsSection = .general + var body: some View { - TabView { + 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)) + } + + 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)) + } + + @ViewBuilder + private var content: some View { + switch section { + case .general: GeneralSettings() - .tabItem { Label("General", systemImage: "gearshape") } + case .privacy: + PrivacySettings() + case .shortcuts: + ShortcutsSettings() + case .sync: + SyncSettings() + case .about: AboutView() - .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 520, height: 560) + } +} + +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 @@ -23,78 +141,254 @@ private struct GeneralSettings: View { #endif var body: some View { - Form { - Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + 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() + + VStack(alignment: .leading, spacing: 4) { + settingToggle( + "Remove saved stacks with clipboard history", + isOn: $settings.pasteStacksFollowHistory + ) + Text("When enabled, deleting or retaining clipboard history also removes matching saved stack clips.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + + Divider() + + HStack { + Text("Erase saved clips now") + .font(.system(size: 14)) + Spacer() + Button("Erase History…", role: .destructive) { + ClipboardStore.shared.clearHistory() + } + } + } + } } - } - Section("Behavior") { - #if !MAS - Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) - #endif - Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) - Toggle("Play sound on paste", isOn: $settings.playSound) - Toggle("Launch at login", isOn: $settings.launchAtLogin) - VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") - Slider(value: $settings.barHeight, in: 300...720, step: 10) + 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 + } + } } - #if MAS - Text("Select a clip to copy it, then press ⌘V to paste it into your app.") - .font(.caption).foregroundStyle(.secondary) - #endif - } - Section("Sync") { - Toggle("Sync clipboard via iCloud Drive", isOn: Binding( - get: { settings.iCloudSync }, - set: { _ in AppController.shared.toggleICloudSync() })) - 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) - } + 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) - #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.")) + Divider() + + Text(settings.selectedClipPosition.detail) .font(.caption) - .foregroundStyle(accessibilityGranted ? .green : .secondary) + .foregroundStyle(.secondary) + .padding(.vertical, 9) } - Spacer() - if !accessibilityGranted { - Button("Open Settings") { - requestedGrant = true - PasteService.ensureAccessibility(prompt: true) - openAccessibilityPane() + } + + 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) } - } else if requestedGrant { - Button("Restart Pesty") { AppController.restart() } } } - } - #endif - Section("Data") { - Button("Clear Clipboard History", role: .destructive) { - ClipboardStore.shared.clearHistory() + 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() + + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + } } + + #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: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) } - .formStyle(.grouped) #if !MAS .onAppear { accessibilityGranted = AXIsProcessTrusted() } .onReceive(poll) { _ in @@ -111,16 +405,319 @@ private struct GeneralSettings: View { } } #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) } + ) + } + + private var clipColorAccent: Binding { + Binding( + get: { Color(hex: settings.clipColorAccentHex) ?? .pink }, + set: { settings.clipColorAccentHex = $0.hexString } + ) + } +} + +private struct PrivacySettings: View { + @Bindable private var settings = Settings.shared + + var body: some View { + 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() + + Button { chooseApps() } label: { + Label("Add App…", systemImage: "plus") + } + .padding(.vertical, 10) + } + } + + 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) + } + } + + 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, 7) + } + + 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 ShortcutsSettings: View { + @Bindable private var settings = Settings.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Open Pesty") { + SettingsSurface { + LabeledContent("Show the Pesty bar") { + HotkeyRecorderView( + keyCode: $settings.hotkeyKeyCode, + modifiers: $settings.hotkeyModifiers + ) + } + .font(.system(size: 14)) + .padding(.vertical, 9) + } + } + + SettingsFormGroup("Paste Stack") { + SettingsSurface { + LabeledContent("Paste next clip") { + HotkeyRecorderView( + keyCode: $settings.sequenceHotkeyKeyCode, + modifiers: $settings.sequenceHotkeyModifiers + ) + } + .font(.system(size: 14)) + .padding(.vertical, 9) + + Text("Start a Paste Stack from the bar, then use this shortcut to paste each clip in order.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 9) + } + } + + 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("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) + } + } + } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} + +private struct SyncSettings: View { + @Bindable private var settings = Settings.shared + + 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) + } + } + } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} + +private struct SettingsFormGroup: View { + let title: String + @ViewBuilder let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + content + } + } +} + +private struct SettingsSurface: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + 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 + + var body: 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 { 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) @@ -130,9 +727,14 @@ 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) + .font(.caption) + .foregroundStyle(.tertiary) } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..188ece6 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) } @@ -14,14 +15,21 @@ 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? - - var historyLimit: Int { - get { Settings.shared.historyLimit } - set { Settings.shared.historyLimit = newValue; trimHistory() } - } + var inlinePreviewVisible = false + /// 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? private var storeURL: URL private var imagesDir: URL @@ -57,6 +65,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -72,11 +81,22 @@ final class ClipboardStore { switch source { case .history: base = history + case .pasteStack: + // Paste Stack entries have their own identity and selection state. + // PasteStackContentView renders them directly instead of folding + // them into the clipboard history strip. + base = [] case .pinboard(let id): base = pinboards.first(where: { $0.id == id })?.items ?? [] } let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return base } + guard !q.isEmpty else { + // Each saved Paste Stack is represented by one deck card on + // Clipboard. Its clips remain in history for persistence, but do + // not also appear as individual Clipboard cards. + guard case .history = source, PasteSequence.shared.hasSavedStacks else { return base } + return base.filter { !PasteSequence.shared.containsHistoryItemID($0.id) } + } return base.filter { $0.searchableText.contains(q) } } @@ -85,45 +105,117 @@ final class ClipboardStore { return visibleItems.first(where: { $0.id == id }) } - func addCaptured(_ item: ClipItem) { + @discardableResult + func addCaptured(_ item: ClipItem) -> ClipItem { if let idx = history.firstIndex(where: { $0.sameContent(as: item) }) { if item.imageFileName != history[idx].imageFileName { deleteImageFile(item) } var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) - if source == .history && searchText.isEmpty { selectedID = existing.id } + applyHistoryPolicyNow() + if source == .history && searchText.isEmpty { selectOnly(existing.id) } scheduleSave() - return + return existing } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { - selectedID = item.id + selectOnly(item.id) } scheduleSave() + return item } - func applyHistoryLimit() { trimHistory(); scheduleSave() } + func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } + + func pasteStacksDidChange() { + 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 } + } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(removed.map(\.id))) + } for item in removed { deleteImageFile(item) } + reconcileSelection() + return true } func delete(_ item: ClipItem) { - history.removeAll { $0.id == item.id } - for i in pinboards.indices { pinboards[i].items.removeAll { $0.id == item.id } } - 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 } + + // 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) } + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(ids) + } + case .pasteStack: + return + 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 + if Settings.shared.pasteStacksFollowHistory { + PasteSequence.shared.removeHistoryItems(Set(old.map(\.id))) + } + if source == .history { + selectOnly(nil) + } else { + reconcileSelection() + } for item in old { deleteImageFile(item) } scheduleSave() } @@ -170,16 +262,170 @@ final class ClipboardStore { scheduleSave() } - func selectFirst() { selectedID = visibleItems.first?.id } + 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 + } + } + + /// 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 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? { @@ -218,6 +464,9 @@ final class ClipboardStore { guard let name = item.imageFileName else { return } let stillUsed = history.contains { $0.imageFileName == name } || pinboards.contains { $0.items.contains { $0.imageFileName == name } } + || PasteSequence.shared.savedStacks.contains { stack in + stack.entries.contains { $0.item.imageFileName == name } + } if stillUsed { return } if let url = imageURL(for: item) { try? FileManager.default.removeItem(at: url) } } @@ -225,6 +474,7 @@ final class ClipboardStore { private struct Snapshot: Codable { var history: [ClipItem] var pinboards: [Pinboard] + var pasteStacks: [SavedPasteStack]? } private func load() { @@ -232,6 +482,7 @@ final class ClipboardStore { let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } history = snap.history pinboards = snap.pinboards + PasteSequence.shared.restoreSavedStacks(snap.pasteStacks ?? []) selectFirst() } @@ -243,7 +494,9 @@ final class ClipboardStore { } func saveNow() { - let snap = Snapshot(history: history, pinboards: pinboards) + let snap = Snapshot(history: history, + pinboards: pinboards, + pasteStacks: PasteSequence.shared.savedStacks) guard let data = try? JSONEncoder().encode(snap) else { return } ignoreWatchUntil = Date().addingTimeInterval(1.5) try? data.write(to: storeURL, options: .atomic) @@ -298,7 +551,8 @@ final class ClipboardStore { var seen = Set() var merged: [ClipItem] = [] for it in combined where seen.insert(contentKey(it)).inserted { merged.append(it) } - history = Array(merged.prefix(historyLimit)) + history = merged + applyHistoryPolicyNow() var byID: [UUID: Pinboard] = Dictionary(uniqueKeysWithValues: pinboards.map { ($0.id, $0) }) for b in snap.pinboards { @@ -314,6 +568,20 @@ final class ClipboardStore { pinboards = pinboards.map { byID[$0.id] ?? $0 } + byID.values.filter { b in !pinboards.contains(where: { $0.id == b.id }) } + var stacksByID: [UUID: SavedPasteStack] = Dictionary( + uniqueKeysWithValues: PasteSequence.shared.savedStacks.map { ($0.id, $0) } + ) + for stack in snap.pasteStacks ?? [] { + if let local = stacksByID[stack.id] { + stacksByID[stack.id] = local.updatedAt >= stack.updatedAt ? local : stack + } else { + stacksByID[stack.id] = stack + } + } + PasteSequence.shared.restoreSavedStacks( + stacksByID.values.sorted { $0.createdAt > $1.createdAt } + ) + combined.removeAll() selectFirst() if history.count != before || !snap.history.isEmpty { saveNow() } diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift new file mode 100644 index 0000000..8c1c18a --- /dev/null +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -0,0 +1,343 @@ +import AppKit +import Observation + +struct PasteStackEntry: Identifiable, Codable { + let id: UUID + let item: ClipItem + /// This stays in memory only. The persisted clip image remains in + /// ClipboardStore and is loaded again after relaunch. + let imagePreview: NSImage? + /// Pasted clips remain in the stack so its deck can show progress and be + /// reset without asking the user to collect the same clips again. + var isPasted: Bool + + init(id: UUID = UUID(), item: ClipItem, imagePreview: NSImage? = nil, isPasted: Bool = false) { + self.id = id + self.item = item + self.imagePreview = imagePreview + self.isPasted = isPasted + } + + private enum CodingKeys: String, CodingKey { case id, item, isPasted } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + item = try container.decode(ClipItem.self, forKey: .item) + isPasted = try container.decode(Bool.self, forKey: .isPasted) + imagePreview = nil + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(item, forKey: .item) + try container.encode(isPasted, forKey: .isPasted) + } +} + +/// One persisted Paste Stack deck. Its entries remain ordered and pasteable, +/// but are represented by a single card in clipboard history. +struct SavedPasteStack: Identifiable, Codable { + let id: UUID + let createdAt: Date + var updatedAt: Date + var entries: [PasteStackEntry] + + init(id: UUID = UUID(), createdAt: Date = .now, entries: [PasteStackEntry] = []) { + self.id = id + self.createdAt = createdAt + self.updatedAt = createdAt + self.entries = entries + } + + var hasEntries: Bool { !entries.isEmpty } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) + } +} + +@Observable +@MainActor +final class PasteSequence { + static let shared = PasteSequence() + + /// The active stack is mirrored here for the existing collection and paste + /// UI. Every mutation is written back to its SavedPasteStack. + private(set) var entries: [PasteStackEntry] = [] + private(set) var isCollecting = false + private(set) var selectedEntryID: UUID? + private(set) var savedStacks: [SavedPasteStack] = [] + private(set) var activeStackID: UUID? + + var hasEntries: Bool { !entries.isEmpty } + var hasSavedStacks: Bool { savedStacks.contains(where: \.hasEntries) } + var pendingCount: Int { entries.count(where: { !$0.isPasted }) } + var pastedCount: Int { entries.count - pendingCount } + /// Keep pending clips at the front and previously pasted clips at the end + /// of the deck. The next clip is therefore always visually first. + var displayEntries: [PasteStackEntry] { + entries.filter { !$0.isPasted } + entries.filter(\.isPasted) + } + var selectedEntry: PasteStackEntry? { + guard let selectedEntryID else { return nil } + return entries.first(where: { $0.id == selectedEntryID }) + } + + func containsHistoryItemID(_ id: UUID) -> Bool { + savedStacks.contains { stack in stack.entries.contains { $0.item.id == id } } + } + + private init() {} + + func restoreSavedStacks(_ stacks: [SavedPasteStack]) { + savedStacks = stacks.sorted { $0.createdAt > $1.createdAt } + guard let newest = savedStacks.first(where: \.hasEntries) else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + return + } + activate(newest) + } + + func selectStack(_ id: UUID) { + guard let stack = savedStacks.first(where: { $0.id == id }) else { return } + activate(stack) + } + + /// Opens the selected stack for collection without discarding saved decks. + func begin() { + ensureActiveStack() + isCollecting = true + if selectedEntryID == nil { selectFirst() } + persistActiveStack() + } + + func pause() { + isCollecting = false + persistActiveStack() + } + + /// Starts a distinct, empty saved stack. Previous stacks remain as decks. + func newStack() { + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] + selectedEntryID = nil + isCollecting = true + persistActiveStack() + } + + @discardableResult + func addIfNeeded(_ item: ClipItem) -> Bool { + ensureActiveStack() + guard isCollecting, + !entries.contains(where: { $0.item.id == item.id }) else { return false } + 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 + } + + func selectFirst() { + selectedEntryID = displayEntries.first?.id + } + + func select(_ entry: PasteStackEntry) { + selectedEntryID = entry.id + } + + func moveSelection(by delta: Int) { + let displayed = displayEntries + guard !displayed.isEmpty else { + selectedEntryID = nil + return + } + guard let selectedEntryID, + let index = displayed.firstIndex(where: { $0.id == selectedEntryID }) else { + self.selectedEntryID = displayed.first?.id + return + } + let next = max(0, min(displayed.count - 1, index + delta)) + self.selectedEntryID = displayed[next].id + } + + func next() -> PasteStackEntry? { + guard let index = entries.firstIndex(where: { !$0.isPasted }) else { + isCollecting = false + persistActiveStack() + return nil + } + return takeEntry(at: index) + } + + func next(entryID: UUID) -> PasteStackEntry? { + guard let index = entries.firstIndex(where: { $0.id == entryID && !$0.isPasted }) else { + return nil + } + return takeEntry(at: index) + } + + func reAdd(_ entry: PasteStackEntry) { + ensureActiveStack() + entries.append(PasteStackEntry(item: entry.item, imagePreview: entry.imagePreview)) + selectFirst() + persistActiveStack() + } + + func remove(_ entry: PasteStackEntry) { + entries.removeAll { $0.id == entry.id } + if selectedEntryID == entry.id { selectFirst() } + persistActiveStack() + } + + /// 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 + selectFirst() + persistActiveStack() + } + + func finishCollecting() { + isCollecting = false + persistActiveStack() + } + + /// Deletes only the active saved stack; all other deck cards remain. + func cancel() { + guard let activeStackID else { return } + savedStacks.removeAll { $0.id == activeStackID } + if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + self.activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() + } + + func deleteStack(_ id: UUID) { + guard savedStacks.contains(where: { $0.id == id }) else { return } + if activeStackID == id { + cancel() + } else { + savedStacks.removeAll { $0.id == id } + ClipboardStore.shared.pasteStacksDidChange() + } + } + + /// Used when the user elects to have saved stacks follow clipboard + /// retention. Empty stacks are removed with their final history item. + func removeHistoryItems(_ ids: Set) { + guard !ids.isEmpty else { return } + for index in savedStacks.indices { + savedStacks[index].entries.removeAll { ids.contains($0.item.id) } + } + savedStacks.removeAll { !$0.hasEntries } + if let activeStackID, + let active = savedStacks.first(where: { $0.id == activeStackID }) { + entries = active.entries + selectFirst() + } else if let next = savedStacks.first(where: \.hasEntries) { + activate(next) + } else { + entries = [] + activeStackID = nil + selectedEntryID = nil + isCollecting = false + } + ClipboardStore.shared.pasteStacksDidChange() + } + + private func takeEntry(at index: Int) -> PasteStackEntry { + var entry = entries.remove(at: index) + entry.isPasted = true + entries.append(entry) + isCollecting = false + selectFirst() + persistActiveStack() + return entry + } + + /// 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 } + if let saved = savedStacks.first(where: \.hasEntries) { + activate(saved) + return + } + let stack = SavedPasteStack() + savedStacks.insert(stack, at: 0) + activeStackID = stack.id + entries = [] + selectedEntryID = nil + } + + private func activate(_ stack: SavedPasteStack) { + activeStackID = stack.id + entries = stack.entries + isCollecting = false + selectFirst() + } + + private func persistActiveStack() { + guard let activeStackID, + let index = savedStacks.firstIndex(where: { $0.id == activeStackID }) else { return } + savedStacks[index].entries = entries + savedStacks[index].updatedAt = .now + ClipboardStore.shared.pasteStacksDidChange() + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..2d086f8 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -1,37 +1,130 @@ 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 + private static let stripTopInset: CGFloat = 4 + private static let stripBottomInset: CGFloat = 18 + @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 { + store.source == .history && store.searchText.isEmpty && sequence.hasSavedStacks + } var body: some View { ZStack { VisualEffectView(material: .hudWindow) Theme.panelTint - } - .overlay(alignment: .top) { VStack(spacing: 0) { + if settings.showBarResizeHandle { resizeHandle } topBar - strip + if store.source == .pasteStack { + PasteStackContentView() + } else { + 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() } + 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 - searchIndicator - PinboardTabs() - .layoutPriority(1) - Spacer(minLength: 8) - moreMenu + GeometryReader { geometry in + HStack(spacing: 14) { + if settings.iCloudSync { + syncButton + } + if store.source != .pasteStack { + searchIndicator(width: searchWidth(in: geometry.size.width)) + } + PinboardTabs() + .layoutPriority(1) + Spacer(minLength: 8) + if store.source != .pasteStack, store.selectedIDs.count > 1 { + bulkDeleteButton + } + if settings.clipPreviewStyle == .inlinePesty, store.source != .pasteStack { + previewButton + } + pasteStackButton + moreMenu + } + .padding(.horizontal, 18) + .frame(width: geometry.size.width, height: geometry.size.height) } - .padding(.horizontal, 18) .frame(height: 56) } + private var previewButton: some View { + 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) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .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 + return min(Self.preferredSearchWidth, max(Self.collapsedSearchWidth, available)) + } + private var syncButton: some View { Button { AppController.shared.toggleICloudSync() @@ -44,7 +137,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 +147,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 { @@ -86,36 +187,101 @@ 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 pasteStackButton: some View { + Button { + AppController.shared.newPasteStack() + } label: { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help("Start Paste Stack") + } + private var strip: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: Theme.cardSpacing) { - 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) { + if showsStackDeck { + ForEach(sequence.savedStacks.filter(\.hasEntries)) { stack in + PasteStackDeckCard( + stack: stack, + isActive: stack.id == sequence.activeStackID, + isCollecting: stack.id == sequence.activeStackID && sequence.isCollecting + ) + .frame(height: cardHeight) + .id(stack.id) + } + } + + ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in + ClipCardView(item: item, + index: index, + 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)) + } } + .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: 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 && !showsStackDeck { emptyState } } } - .overlay { if store.visibleItems.isEmpty { emptyState } } } .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") @@ -130,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 cbd1c2a..97cbffb 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 @@ -68,8 +75,29 @@ 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() + guard Settings.shared.hideOnClickOutside, + !isPresenting, + !AppController.shared.suppressAutoHide else { return } + // 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, + !ClipboardStore.shared.inlinePreviewVisible else { return } + AppController.shared.hideBar() + } } } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..bab35bb 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -7,7 +8,13 @@ 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) } + 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) { @@ -29,7 +36,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 } } @@ -38,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) @@ -97,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)) @@ -143,8 +157,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)) } @@ -163,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: @@ -173,29 +187,131 @@ struct ClipCardView: View { @ViewBuilder private var menu: some View { - Button("Paste") { AppController.shared.pasteItem(item) } - Button("Copy") { AppController.shared.copyItem(item) } - Divider() - if !store.pinboards.isEmpty { - Menu("Save to Pinboard") { - ForEach(store.pinboards) { b in - Button(b.name) { store.saveToPinboard(item, boardID: b.id) } - } + 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) + + 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() + + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") } - 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) + .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.line") + } + .keyboardShortcut("r", modifiers: .command) + + Button(role: .destructive) { store.deleteSelection(containing: item) } label: { + Label("Delete", systemImage: "trash") } - Button("Edit Title…") { - if let t = TextPrompt.run(title: "Edit Title", message: "Card title", - defaultValue: item.customTitle ?? "") { - store.setTitle(t, for: item) + .keyboardShortcut(.delete, modifiers: []) + + Divider() + + Menu { + if store.pinboards.isEmpty { + Button("No Pinboards Yet") {} + .disabled(true) + } else { + ForEach(store.pinboards) { b in + Button { store.saveToPinboard(item, boardID: b.id) } label: { + Label { + Text(b.name) + } icon: { + Image(nsImage: Self.pinboardMenuIcon(color: NSColor(b.color))) + .renderingMode(.original) + } + } + } + } + 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 var isInPasteStack: Bool { + PasteSequence.shared.containsHistoryItemID(item.id) + } + + 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) + } + } + + 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 + } + + /// 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 } } 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 + } +} diff --git a/Sources/Pesty/UI/ClipPreviewView.swift b/Sources/Pesty/UI/ClipPreviewView.swift new file mode 100644 index 0000000..86e850b --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewView.swift @@ -0,0 +1,98 @@ +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.presentationType.symbol) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(item.presentationType.accent) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(item.displayTitle) + .font(.headline) + .lineLimit(2) + Text(item.presentationType.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: + 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) + } + } + } + } + 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)) + } +} diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift new file mode 100644 index 0000000..71e1b1d --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -0,0 +1,336 @@ +import AppKit +import SwiftUI +import WebKit + +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 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) + .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 externalActionTitle = InlinePreviewExternalOpener.actionTitle(for: item) { + Button(externalActionTitle) { InlinePreviewExternalOpener.open(item) } + .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.presentationType.symbol) + .foregroundStyle(item.presentationType.accent) + Text(item.presentationType.label) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(secondaryText) + Spacer() + Text(item.createdAt.clipRelativeLong) + .font(.system(size: 11)) + .foregroundStyle(secondaryText) + } + previewContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + Text(item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(secondaryText) + .lineLimit(2) + } + .padding(16) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) + } + + @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(primaryText) + .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(secondaryText) + .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(primaryText) + 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(primaryText) + .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 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..7c994fa --- /dev/null +++ b/Sources/Pesty/UI/InlinePreviewWindowController.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +private final class InlinePreviewPanel: NSPanel { + // 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 } +} + +/// 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) + // 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() { + 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/UI/PasteStackDeckCard.swift b/Sources/Pesty/UI/PasteStackDeckCard.swift new file mode 100644 index 0000000..a82ada0 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackDeckCard.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// A compact representation of one saved Paste Stack in the Clipboard strip. +/// It opens that stack rather than exposing its clips as history cards. +struct PasteStackDeckCard: View { + let stack: SavedPasteStack + let isActive: Bool + let isCollecting: Bool + + private var nextEntry: PasteStackEntry? { + stack.displayEntries.first(where: { !$0.isPasted }) + } + + var body: some View { + Button { AppController.shared.showPasteStackTab(stackID: stack.id) } label: { + ZStack(alignment: .topLeading) { + if stack.pendingCount > 2 { deckLayer(offset: 12, opacity: 0.20) } + if stack.pendingCount > 1 { deckLayer(offset: 6, opacity: 0.34) } + frontCard + } + .frame(width: Theme.cardWidth + 12, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + } + .buttonStyle(.plain) + .help("Open Paste Stack") + } + + private func deckLayer(offset: CGFloat, opacity: Double) -> some View { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .fill(Theme.selection.opacity(opacity)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(Theme.selection.opacity(0.25)) + } + .offset(x: offset, y: offset) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + + private var frontCard: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "rectangle.stack.fill") + .font(.system(size: 16, weight: .semibold)) + VStack(alignment: .leading, spacing: 1) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(Theme.headerSubText) + } + Spacer(minLength: 4) + Text("\(stack.pendingCount)") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.18), in: Capsule()) + } + .foregroundStyle(Theme.headerText) + .padding(.horizontal, 13) + .frame(height: Theme.headerHeight) + .background(Theme.selection) + + VStack(spacing: 10) { + if let entry = nextEntry { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 50, height: 50) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + Text(entry.item.displayTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(3) + .multilineTextAlignment(.center) + Text("Next clip") + .font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary) + } else { + Image(systemName: "checkmark.circle") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Stack complete") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.cardBody) + + HStack { + Text("\(stack.pendingCount) queued") + Spacer() + Text("Open stack") + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Theme.cardBody) + } + .frame(width: Theme.cardWidth) + .frame(maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) + .strokeBorder(isActive ? Theme.selection.opacity(0.9) : Theme.selection.opacity(0.45), + lineWidth: isActive ? 2 : 1) + } + .shadow(color: .black.opacity(0.16), radius: 5, y: 2) + } + + private var statusText: String { + if isActive && isCollecting { return "Collecting clips" } + if stack.pendingCount > 0 { return "Ready to paste" } + return "Completed stack" + } +} diff --git a/Sources/Pesty/UI/PasteStackView.swift b/Sources/Pesty/UI/PasteStackView.swift new file mode 100644 index 0000000..7e474a8 --- /dev/null +++ b/Sources/Pesty/UI/PasteStackView.swift @@ -0,0 +1,540 @@ +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) { + header + Divider() + entries + Divider() + footer + } + .frame(width: 320, height: 380) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.25)) + } + } + + private var header: some View { + HStack(spacing: 9) { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 15, weight: .bold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + AppController.shared.showPasteStackTab() + } label: { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(Theme.selection) + } + .buttonStyle(.plain) + .help("Open Paste Stack in Pesty") + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Image(systemName: stack.isCollecting ? "pause.circle.fill" : "play.circle.fill") + .foregroundStyle(stack.isCollecting ? .orange : Theme.selection) + } + .buttonStyle(.plain) + .help(stack.isCollecting ? "Pause collecting clips" : "Resume collecting clips") + Button { + AppController.shared.hidePasteStack() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Hide Paste Stack") + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(Theme.selection) + Text(stack.isCollecting + ? "Copy text, images, or files in another app to collect them here." + : "Start a new Paste Stack from the strip.") + .font(.system(size: 12, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: 210) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 7) { + ForEach(Array(stack.displayEntries.filter { !$0.isPasted }.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + 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 + ) + } + 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) + } + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button { + AppController.shared.pasteNextInSequence() + } label: { + Label("Paste Next", systemImage: "doc.on.clipboard") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(stack.pendingCount == 0) + + Text(Settings.shared.sequenceHotkeyDisplay) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + if stack.hasEntries { + Button("Save") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") + Button { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Clear Paste Stack") + } + } + .padding(.horizontal, 15) + .frame(height: 52) + } + + private var summary: String { + if stack.isCollecting { + return stack.hasEntries ? "\(stack.pendingCount) collected - copy more" : "Copy clips in another app" + } + if stack.pendingCount > 0 { return "\(stack.pendingCount) ready to paste" } + return stack.hasEntries ? "Stack complete" : "Collection paused" + } +} + +private struct PasteStackEntryRow: View { + let entry: PasteStackEntry + let index: Int + let selected: Bool + let showsPasteAction: Bool + + private var stack: PasteSequence { AppController.shared.pasteSequence } + + var body: some View { + HStack(spacing: 10) { + 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) + .frame(width: 18) + preview + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Text(entry.item.type.label) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 6) { + if showsPasteAction { + if entry.isPasted { + Button("Re-add") { AppController.shared.reAddPasteStackEntry(entry) } + .buttonStyle(.bordered) + .controlSize(.mini) + } else { + Button("Paste") { AppController.shared.pasteStackEntry(entry) } + .buttonStyle(.borderedProminent) + .controlSize(.mini) + } + } + HStack(spacing: 7) { + sourceAppIcon + Button { AppController.shared.removePasteStackEntry(entry) } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Remove from Paste Stack") + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(rowBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(selected ? Theme.selection : .clear, lineWidth: selected ? 2 : 0) + } + .opacity(entry.isPasted ? 0.52 : 1) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .onTapGesture { stack.select(entry) } + .help(entry.isPasted ? "Pasted clip" : "Select this stack clip") + } + + @ViewBuilder + private var preview: some View { + if let image = previewImage { + Image(nsImage: image) + .resizable() + .interpolation(.medium) + .scaledToFill() + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(entry.item.type.accent.opacity(0.18)) + .frame(width: 38, height: 38) + .overlay { + Image(systemName: entry.item.type.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(entry.item.type.accent) + } + } + } + + private var previewImage: NSImage? { + if entry.item.type == .image { + return entry.imagePreview ?? ClipboardStore.shared.loadImage(for: entry.item) + } + guard entry.item.type == .file, + entry.item.fileURLs.count == 1, + let urlString = entry.item.fileURLs.first, + let url = URL(string: urlString), + url.isFileURL else { return nil } + return NSImage(contentsOf: url) + } + + private var sourceAppIcon: some View { + Image(nsImage: AppIconProvider.icon(forBundleID: entry.item.sourceBundleID)) + .resizable() + .interpolation(.high) + .frame(width: 19, height: 19) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + .help(entry.item.sourceAppName ?? "Source app") + } + + private var rowBackground: Color { + selected ? Theme.selection.opacity(0.13) : Color.white.opacity(0.10) + } +} + +/// The full Paste Stack tab in Pesty. It exposes the active deck without +/// turning its entries into ordinary clipboard-history cards. +struct PasteStackContentView: View { + private var stack: PasteSequence { AppController.shared.pasteSequence } + @State private var draggedEntryID: UUID? + @State private var dropTargetEntryID: UUID? + @State private var isDropTargetAtEnd = false + + var body: some View { + VStack(spacing: 0) { + header + Divider() + entries + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 9) { + VStack(alignment: .leading, spacing: 2) { + Text("Paste Stack") + .font(.system(size: 16, weight: .bold)) + Text(summary) + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + if savedStacks.count > 1 { + Menu { + ForEach(savedStacks) { saved in + Button(stackLabel(for: saved)) { + stack.selectStack(saved.id) + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Choose a saved Paste Stack") + } + Spacer() + + Button { + if stack.isCollecting { + AppController.shared.pausePasteSequence() + } else { + AppController.shared.beginPasteSequence() + } + } label: { + Label(stack.isCollecting ? "Pause" : "Collect", + systemImage: stack.isCollecting ? "pause.fill" : "play.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(stack.isCollecting ? .orange : Theme.selection) + + if stack.pendingCount > 0 { + Button("Paste Next") { AppController.shared.pasteNextInSequence() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + if stack.pastedCount > 0 { + Button("Reset") { AppController.shared.resetPasteStackProgress() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Make every Paste Stack clip ready again") + } + + if stack.hasEntries { + Button("Save Stack…") { AppController.shared.savePasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Save Paste Stack as a Pinboard") + } + + Button("New Stack") { AppController.shared.newPasteStack() } + .buttonStyle(.bordered) + .controlSize(.small) + + if stack.hasEntries { + Button(role: .destructive) { AppController.shared.clearPasteStack() } label: { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Clear Paste Stack") + } + } + .padding(.horizontal, 24) + .padding(.vertical, 13) + } + + @ViewBuilder + private var entries: some View { + if stack.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(Theme.selection) + Text("Start collecting clips") + .font(.system(size: 15, weight: .semibold)) + Text("Choose Collect, then copy text, images, or files in any app.") + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 8) { + ForEach(Array(stack.displayEntries.filter { !$0.isPasted }.enumerated()), id: \.element.id) { index, entry in + PasteStackEntryRow(entry: entry, + 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 + ) + } + 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) + } + } + } + + private var summary: String { + if stack.isCollecting { return "Collecting clips from other apps" } + if stack.pendingCount > 0 { return "\(stack.pendingCount) clips ready to paste" } + return stack.hasEntries ? "All clips pasted" : "Collection paused" + } + + private var savedStacks: [SavedPasteStack] { + stack.savedStacks.filter(\.hasEntries) + } + + private func stackLabel(for saved: SavedPasteStack) -> String { + let state = saved.pendingCount > 0 ? "\(saved.pendingCount) ready" : "completed" + return "\(saved.createdAt.clipRelativeLong) · \(state)" + } +} + +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 + } +} diff --git a/Sources/Pesty/UI/PasteStackWindowController.swift b/Sources/Pesty/UI/PasteStackWindowController.swift new file mode 100644 index 0000000..055799f --- /dev/null +++ b/Sources/Pesty/UI/PasteStackWindowController.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +final class PasteStackPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +@MainActor +final class PasteStackWindowController: NSWindowController { + init() { + let panel = PasteStackPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 380), + styleMask: [.borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .modalPanel + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: PasteStackView()) + super.init(window: panel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + func show() { + guard let panel = window else { return } + let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return } + + let visible = screen.visibleFrame + let origin = NSPoint(x: visible.maxX - panel.frame.width - 22, + y: visible.maxY - panel.frame.height - 22) + panel.setFrameOrigin(origin) + panel.orderFrontRegardless() + panel.makeKey() + } + + func hide() { + window?.orderOut(nil) + } +} diff --git a/Sources/Pesty/UI/PinboardTabs.swift b/Sources/Pesty/UI/PinboardTabs.swift index 01aa291..379e9da 100644 --- a/Sources/Pesty/UI/PinboardTabs.swift +++ b/Sources/Pesty/UI/PinboardTabs.swift @@ -2,6 +2,7 @@ import SwiftUI struct PinboardTabs: View { @Bindable private var store = ClipboardStore.shared + private var stack: PasteSequence { AppController.shared.pasteSequence } var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -13,6 +14,14 @@ struct PinboardTabs: View { store.source = .history; store.selectFirst() } + pill(title: "Paste Stack", + dot: nil, + icon: "rectangle.stack.fill", + badge: stack.pendingCount, + selected: store.source == .pasteStack) { + AppController.shared.showPasteStackTab() + } + ForEach(store.pinboards) { board in pill(title: board.name, dot: board.color, @@ -41,6 +50,7 @@ struct PinboardTabs: View { } private func pill(title: String, dot: Color?, icon: String? = nil, + badge: Int? = nil, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { @@ -54,6 +64,14 @@ struct PinboardTabs: View { Text(title) .font(.system(size: 12.5, weight: .medium)) .lineLimit(1) + if let badge, badge > 0 { + Text("\(badge)") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(selected ? .white : Theme.selection) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(selected ? Theme.selection : Theme.selection.opacity(0.14), in: Capsule()) + } } .foregroundStyle(selected ? Theme.textPrimary : Theme.textSecondary) .padding(.horizontal, 12) 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/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) + } +} diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift new file mode 100644 index 0000000..c617058 --- /dev/null +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -0,0 +1,95 @@ +import AppKit +import Foundation +import Observation + +struct LinkPreview { + var title: String? + var icon: NSImage? + var image: 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 metadata = data.flatMap { Self.pageMetadata(from: $0, relativeTo: url) } + DispatchQueue.main.async { + 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() + + 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, image: nil, finished: true) + } + }.resume() + } + + 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 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]), + let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let range = Range(match.range(at: 1), in: html) else { return nil } + 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 new file mode 100644 index 0000000..0d8632f --- /dev/null +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -0,0 +1,157 @@ +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: clip.presentationType.label) } } + case .image: + if let url = ClipboardStore.shared.imageURL(for: clip) { + 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.presentationType.label)] + } + 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: + 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.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.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.presentationType.label)] + } + + 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 } + } + + /// 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 { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String) { + previewItemURL = url + previewItemTitle = title + } +} diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..8bc0cff 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,8 +1,9 @@ +import AppKit import SwiftUI @MainActor enum SourceColor { - private static let palette: [Color] = [ + 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), @@ -16,18 +17,153 @@ enum SourceColor { 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 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 let key = "appColorMap" - private static var map: [String: Int] = { - UserDefaults.standard.dictionary(forKey: key) as? [String: Int] ?? [:] + 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 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] + 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] { + accentVariants.map { accentShade(variant: $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: accentVariants.count) + return accentShade(variant: accentVariants[index], accentHex: accentHex) + } + + private static func accentShade(variant: AccentVariant, 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) + + // 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: adjustedHue, + saturation: min(0.98, max(0.60, Double(saturation) + variant.saturationOffset)), + brightness: min(0.98, max(0.32, middleBrightness + variant.brightnessOffset)) + ) + } + + 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 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)) + 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) : vibrantFallback + } + + 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))) } }