diff --git a/Package.swift b/Package.swift index a046d31..5012d03 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,11 @@ let package = Package( swiftSettings: [ .unsafeFlags(["-parse-as-library"]) ] + ), + .testTarget( + name: "PestyTests", + dependencies: ["Pesty"], + path: "Tests/PestyTests" ) ], swiftLanguageModes: [.v5] diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d751356..cb6a4a7 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -8,6 +8,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { let store = ClipboardStore.shared let monitor = ClipboardMonitor() + private let copyToast = CopyToastController() private var barController: BarWindowController? private var statusItem: NSStatusItem? @@ -16,6 +17,9 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { private var previewWindow: NSWindow? private var previewedItemID: UUID? private var keyMonitor: Any? + #if !MAS + private var notedBlockedDirectPaste = false + #endif private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? @@ -83,6 +87,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } func applicationWillTerminate(_ notification: Notification) { + store.forgetDeletions() store.saveNow() } @@ -236,6 +241,26 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { ClipboardStore.shared.setICloudSync(enabling) } + func beginBarResize(at screenPoint: NSPoint) { + barController?.beginBarResize(at: screenPoint) + } + + func updateBarResize(at screenPoint: NSPoint) { + barController?.updateBarResize(at: screenPoint) + } + + func endBarResize(at screenPoint: NSPoint) { + barController?.endBarResize(at: screenPoint) + } + + func cancelBarResize() { + barController?.cancelBarResize() + } + + func adjustBarHeight(by delta: CGFloat) { + barController?.adjustBarHeight(by: delta) + } + static func restart() { let path = Bundle.main.bundlePath let task = Process() @@ -279,6 +304,12 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { previousApp = front lastActiveApp = front } + // The panel keeps its first responder across orderOut, so a field + // left focused by a hide mid-search would otherwise own the next + // presentation's keys: Return would not paste and arrows would move + // a caret in an empty query. + barController?.resignSearch() + store.barInputMode = .cards store.searchText = "" store.source = .history store.prepareForBarPresentation() @@ -297,6 +328,38 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { barController?.hide() } + func setBarSearchEditing(_ editing: Bool) { + let mode: BarInputMode = editing ? .search : .cards + guard store.barInputMode != mode else { return } + store.barInputMode = mode + } + + /// Routes every edit from the native search field through here instead + /// of writing `store.searchText` directly, so the selection follows the + /// filtered results as you type instead of staying on whatever was + /// selected before the query changed. + func updateBarSearchText(_ text: String) { + guard store.searchText != text else { return } + store.searchText = text + store.selectFirst() + } + + func clearBarSearch() { + let hadQuery = !store.searchText.isEmpty + barController?.resignSearch() + store.searchText = "" + store.barInputMode = .cards + if hadQuery { store.selectFirst(); searchClearedAt = Date() } + } + + func cancelBarSearchOrHide() { + if !store.searchText.isEmpty { + clearBarSearch() + } else { + hideBar() + } + } + func pasteSelected() { guard let item = store.selectedItem else { return } pasteItem(item) @@ -317,18 +380,49 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { return app.bundleIdentifier == bundleID } - func pasteItem(_ item: ClipItem, asPlainText: Bool = false) { + func pasteItem(_ item: ClipItem, format: PasteFormat = .original) { let target = pasteTarget hideBar() - PasteService.paste(item, into: target, monitor: monitor, asPlainText: asPlainText) + let previousChange = NSPasteboard.general.changeCount + PasteService.paste(item, into: target, monitor: monitor, format: format) + if Settings.shared.promoteOnPaste, NSPasteboard.general.changeCount != previousChange { + store.promoteCopiedItem(item) + } + } + + #if !MAS + /// A direct paste that quietly falls back to a copy reads as "paste is + /// broken". Say so once per launch, in the toast rather than a modal: + /// the clip is on the pasteboard and Command-V finishes the job. + func noteBlockedDirectPaste() { + guard !notedBlockedDirectPaste else { return } + notedBlockedDirectPaste = true + copyToast.show(message: "Copied. Grant Accessibility in System Settings to paste directly.", + symbol: "exclamationmark.triangle.fill", + linger: 4) } + #endif func copyItem(_ item: ClipItem) { + let previousChange = NSPasteboard.general.changeCount let change = PasteService.copy(item) monitor.suppressUntilChangeCount = change + // PasteService.copy leaves the pasteboard alone when an image file is + // missing; neither the promotion nor the toast should claim otherwise. + if change != previousChange { + store.promoteCopiedItem(item) + copyToast.show() + } + // Tink, not Pop: copy and paste stay audibly distinct. + if Settings.shared.playSoundOnCopy { NSSound(named: "Tink")?.play() } hideBar() } + func copySelected() { + guard let item = store.selectedItem else { return } + copyItem(item) + } + var pasteMenuTitle: String { guard let name = pasteTarget?.localizedName, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -405,28 +499,34 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } } - func deleteEffectiveSelection() { + /// `permanently` skips Undo for this one deletion: the user held Option. + func deleteEffectiveSelection(permanently: Bool = false) { let selection = store.effectiveSelectionIDs let targets = store.visibleItems.filter { selection.contains($0.id) } guard !targets.isEmpty else { return } if targets.count == 1 { - store.delete(targets[0]) + store.delete(targets[0], permanently: permanently) return } suppressAutoHide = true defer { suppressAutoHide = false } let alert = NSAlert() alert.messageText = "Delete \(targets.count) Clips?" + let noUndo = permanently || Settings.shared.deletePermanently #if MAS - alert.informativeText = "There is no undo. When iCloud sync is on, these clips are also removed from your other devices." + alert.informativeText = noUndo + ? "This cannot be undone. When iCloud sync is on, these clips are also removed from your other devices." + : "You can undo this with ⌘Z for the next 5 minutes. When iCloud sync is on, these clips are also removed from your other devices." #else - alert.informativeText = "There is no undo." + alert.informativeText = noUndo + ? "This cannot be undone." + : "You can undo this with ⌘Z for the next 5 minutes." #endif let confirm = alert.addButton(withTitle: "Delete \(targets.count) Clips") confirm.hasDestructiveAction = true alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } - store.delete(items: targets) + store.delete(items: targets, permanently: permanently) } func deleteSelection(containing item: ClipItem) { @@ -492,7 +592,10 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // Events belonging to a native context menu, editor, alert, or the // Settings window must stay with their own responder chain. The bar // monitor is only responsible for keys delivered to the panel itself. - guard event.window === barController?.window else { return event } + guard let barWindow = barController?.window, + event.window === barWindow else { return event } + + if barWindow.firstResponder is BarResizeHandleResponder { return event } if handleBarCommandShortcut(event) { return nil } @@ -508,11 +611,20 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { if digit <= items.count { let plain = includes(Settings.shared.plainTextModifier, in: flags) && Settings.shared.plainTextModifier != Settings.shared.quickPasteModifier - pasteItem(items[digit - 1], asPlainText: plain) + pasteItem(items[digit - 1], format: plain ? .plainText : .original) } return nil } + // Past the quick-paste digits, the native search field owns the event + // while it is editing: selection, clipboard commands, deletion, spaces, + // keyboard layouts, and composed text all need real AppKit + // text-editing behavior. Return and the arrows come back through the + // field's delegate. + if barController?.searchOwnsFirstResponder == true { + return event + } + switch code { case kVK_Escape: if !store.multiSelectedIDs.isEmpty { @@ -535,8 +647,17 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { case kVK_DownArrow: store.moveSelection(by: 1); return nil case kVK_Delete: - if cmd { deleteEffectiveSelection(); return nil } + if cmd { deleteEffectiveSelection(permanently: opt); return nil } + // Backspace edits the query before it can remove a filtered clip, + // even when focus has already moved back to the cards (e.g. + // after Return submitted the search). Refocusing the native + // field and returning the same event lets it handle the + // backspace itself, rather than manually mutating the string. if !store.searchText.isEmpty { + store.barInputMode = .search + if barController?.focusSearchAtEnd() == true { + return event + } store.searchText.removeLast(); store.selectFirst() if store.searchText.isEmpty { searchClearedAt = Date() } return nil @@ -546,26 +667,38 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // Backspace that just finished clearing a query must not start // deleting clips at the key-repeat rate - and a short cooldown // after the search empties separates "clear the query" from - // "delete a clip". There is no undo, and deletions replicate to + // "delete a clip". Command-Z brings a deleted clip back for five + // minutes unless Option was held, and deletions replicate to // other devices when sync is on. if !event.isARepeat, Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown { - deleteEffectiveSelection() + deleteEffectiveSelection(permanently: opt) } return nil case kVK_ForwardDelete: - deleteEffectiveSelection() + deleteEffectiveSelection(permanently: opt) return nil + case kVK_ANSI_Z: + if cmd, !ctrl, !opt, !flags.contains(.shift), store.undoLastDelete() { return nil } + case kVK_ANSI_C: + if cmd, !ctrl, !opt, !flags.contains(.shift) { copySelected(); return nil } default: break } - if !cmd && !ctrl && !opt, - let chars = event.characters, chars.count == 1, - let scalar = chars.unicodeScalars.first, - scalar.value >= 32, scalar.value != 127 { - store.searchText.append(chars) - store.selectFirst() + if isPrintableTextIntent(event) { + store.barInputMode = .search + if barController?.focusSearchAtEnd() == true { + // The local monitor runs before responder dispatch. Returning + // the same event now sends its very first character directly + // to the newly focused native field editor — no character is + // lost transferring focus mid-keystroke. + return event + } + if let chars = fallbackSearchCharacters(from: event) { + store.searchText.append(chars) + store.selectFirst() + } return nil } return event @@ -584,6 +717,33 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { store.selectFirst() } + private func isPrintableTextIntent(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags + guard !flags.contains(.command), + !flags.contains(.control), + let chars = event.charactersIgnoringModifiers, + !chars.isEmpty else { return false } + return chars.unicodeScalars.contains { + $0.value >= 0x20 + && $0.value != 0x7F + && !(0xF700...0xF8FF).contains($0.value) + } + } + + private func fallbackSearchCharacters(from event: NSEvent) -> String? { + let flags = event.modifierFlags + guard !flags.contains(.command), + !flags.contains(.control), + let chars = event.characters, + !chars.isEmpty, + chars.unicodeScalars.allSatisfy({ + $0.value >= 0x20 + && $0.value != 0x7F + && !(0xF700...0xF8FF).contains($0.value) + }) else { return nil } + return chars + } + private static func quickPasteDigit(for keyCode: Int) -> Int? { switch keyCode { case kVK_ANSI_1, kVK_ANSI_Keypad1: return 1 diff --git a/Sources/Pesty/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index eafd514..ecdb58e 100644 --- a/Sources/Pesty/Models/ClipItem.swift +++ b/Sources/Pesty/Models/ClipItem.swift @@ -5,6 +5,7 @@ struct ClipItem: Identifiable, Codable, Equatable { var type: ClipType var text: String? var rtfData: Data? + var htmlData: Data? var imageFileName: String? var imageHash: String? var fileURLs: [String] @@ -20,6 +21,7 @@ struct ClipItem: Identifiable, Codable, Equatable { type: ClipType, text: String? = nil, rtfData: Data? = nil, + htmlData: Data? = nil, imageFileName: String? = nil, imageHash: String? = nil, fileURLs: [String] = [], @@ -32,6 +34,7 @@ struct ClipItem: Identifiable, Codable, Equatable { self.type = type self.text = text self.rtfData = rtfData + self.htmlData = htmlData self.imageFileName = imageFileName self.imageHash = imageHash self.fileURLs = fileURLs diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 2590070..1293c13 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -4,6 +4,7 @@ import CryptoKit @MainActor final class ClipboardMonitor { private static let sourceType = NSPasteboard.PasteboardType("org.nspasteboard.source") + private static let htmlCaptureLimit = 64 * 1024 private let pasteboard = NSPasteboard.general private var lastChangeCount: Int private var timer: Timer? @@ -99,6 +100,14 @@ final class ClipboardMonitor { } let rtf = pasteboard.data(forType: .rtf) + // Browsers often provide HTML with no RTF; keep it for the Markdown + // and Clean Formatting paste conversions without changing how the + // clip is classified. RTF already serves those conversions, so HTML + // is stored only when there is none, and only up to a bound that + // keeps store.json, rewritten on every capture, from growing by the + // size of every web page copied from. + var html = rtf == nil ? pasteboard.data(forType: .html) : nil + if let data = html, data.count > Self.htmlCaptureLimit { html = nil } if let string = pasteboard.string(forType: .string), !string.isEmpty { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) let type: ClipType @@ -109,7 +118,7 @@ final class ClipboardMonitor { } else { type = .text } - var item = ClipItem(type: type, text: string, rtfData: rtf) + var item = ClipItem(type: type, text: string, rtfData: rtf, htmlData: html) decorate(&item) return item } diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 4645f5f..1cce40e 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -1,6 +1,19 @@ import AppKit import Carbon.HIToolbox +/// How a clip's content is written to the pasteboard when pasting. +enum PasteFormat { + /// The clip exactly as captured. + case original + /// Text only, all formatting removed. + case plainText + /// Bold, italic, underline, and links survive; fonts, sizes, and colors + /// are normalized away. + case cleanFormatting + /// Rich content converted to Markdown text. + case markdown +} + @MainActor enum PasteService { private static let sourceType = NSPasteboard.PasteboardType("org.nspasteboard.source") @@ -13,13 +26,40 @@ enum PasteService { @discardableResult static func copy(_ item: ClipItem, - asPlainText: Bool = false, + format: PasteFormat = .original, to pasteboard: NSPasteboard = .general) -> Int { - if asPlainText, let text = item.plainText { - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - markSource(of: item, on: pasteboard) - return pasteboard.changeCount + switch format { + case .plainText: + if let text = item.plainText { + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + markSource(of: item, on: pasteboard) + return pasteboard.changeCount + } + case .cleanFormatting: + if let rtf = FormatConverter.cleanedRTF(for: item) { + pasteboard.clearContents() + pasteboard.setData(rtf, forType: .rtf) + if let text = item.plainText { pasteboard.setString(text, forType: .string) } + markSource(of: item, on: pasteboard) + return pasteboard.changeCount + } + // No rich source to clean: plain text is the honest result. + if item.plainText != nil { + return copy(item, format: .plainText, to: pasteboard) + } + case .markdown: + if let markdown = FormatConverter.markdown(for: item) { + pasteboard.clearContents() + pasteboard.setString(markdown, forType: .string) + markSource(of: item, on: pasteboard) + return pasteboard.changeCount + } + if item.plainText != nil { + return copy(item, format: .plainText, to: pasteboard) + } + case .original: + break } if item.type == .image { guard let img = ClipboardStore.shared.loadImage(for: item) else { @@ -56,8 +96,8 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, monitor: ClipboardMonitor, - asPlainText: Bool = false) { - let change = copy(item, asPlainText: asPlainText) + format: PasteFormat = .original) { + let change = copy(item, format: format) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } @@ -71,7 +111,11 @@ enum PasteService { #else // Direct-download build: optionally paste straight into the active app by // synthesizing ⌘V. This requires the user's Accessibility grant. - guard Settings.shared.pasteDirectly && AXIsProcessTrusted() else { return } + guard Settings.shared.pasteDirectly else { return } + guard AXIsProcessTrusted() else { + AppController.shared.noteBlockedDirectPaste() + return + } target.activate() waitForFrontmost(target, attempts: 20) #endif diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..3adf605 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -59,6 +59,133 @@ enum HistoryRetentionMode: String, CaseIterable, Identifiable { } } +/// The discrete stops on the time-based retention slider. `historyRetentionDays` +/// stays the underlying source of truth (a raw day count, with 0 meaning +/// "forever" - never prune by age) - this just names the stops so the slider +/// can snap between them instead of offering a raw number field. +enum HistoryRetentionPreset: Int, CaseIterable, Identifiable { + case day + case week + case twoWeeks + case threeWeeks + case month + case twoMonths + case threeMonths + case sixMonths + case year + case forever + + var id: Int { rawValue } + + /// 0 means forever - no automatic time-based pruning. + var days: Int { + switch self { + case .day: return 1 + case .week: return 7 + case .twoWeeks: return 14 + case .threeWeeks: return 21 + case .month: return 30 + case .twoMonths: return 60 + case .threeMonths: return 90 + case .sixMonths: return 180 + case .year: return 365 + case .forever: return 0 + } + } + + var title: String { + switch self { + case .day: return "1 Day" + case .week: return "1 Week" + case .twoWeeks: return "2 Weeks" + case .threeWeeks: return "3 Weeks" + case .month: return "1 Month" + case .twoMonths: return "2 Months" + case .threeMonths: return "3 Months" + case .sixMonths: return "6 Months" + case .year: return "1 Year" + case .forever: return "Forever" + } + } + + var shortTitle: String { + switch self { + case .day: return "1d" + case .week: return "1w" + case .twoWeeks: return "2w" + case .threeWeeks: return "3w" + case .month: return "1m" + case .twoMonths: return "2m" + case .threeMonths: return "3m" + case .sixMonths: return "6m" + case .year: return "1y" + case .forever: return "∞" + } + } + + var sliderIndex: Double { Double(rawValue) } + + /// Snaps an arbitrary day count (including ones from before this preset + /// set existed) to its nearest stop, so old UserDefaults values still + /// land on a sensible position on the slider. + init(nearestDays days: Int) { + self = Self.allCases.min(by: { abs($0.days - days) < abs($1.days - days) }) ?? .month + } + + init(sliderIndex: Double) { + let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded()))) + self = Self.allCases[index] + } +} + +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: + "Match each clip to its source app\u{2019}s familiar card color." + case .vibrant: + "Use a stronger, higher-contrast version of each source app color." + case .accentShades: + "Give each source app a stable lighter or darker shade of one color." + } + } +} + +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." + } + } +} + @Observable @MainActor final class Settings { @@ -79,13 +206,20 @@ final class Settings { static let hideOnClickOutside = "hideOnClickOutside" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" + static let playSoundOnCopy = "playSoundOnCopy" + static let promoteOnPaste = "promoteOnPaste" static let ignoreConcealed = "ignoreConcealed" + static let deletePermanently = "deletePermanently" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" static let cloudKitSync = "cloudKitSync" + static let clipColorTheme = "clipColorTheme" + static let clipColorAccentHex = "clipColorAccentHex" + static let selectedClipPosition = "selectedClipPosition" } var historyLimit: Int { @@ -103,10 +237,13 @@ final class Settings { } } + /// A raw day count for time-based pruning; 0 means forever (no automatic + /// pruning by age). See `HistoryRetentionPreset` for the named stops the + /// Settings slider snaps this to. var historyRetentionDays: Int { didSet { guard isLoaded else { return } - if historyRetentionDays < 1 { historyRetentionDays = 1; return } + if historyRetentionDays < 0 { historyRetentionDays = 0; return } d.set(historyRetentionDays, forKey: Keys.historyRetentionDays) } } @@ -154,10 +291,28 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(playSound, forKey: Keys.playSound) } } + var playSoundOnCopy: Bool { + didSet { guard isLoaded else { return }; d.set(playSoundOnCopy, forKey: Keys.playSoundOnCopy) } + } + + /// Pasting a clip moves it to the front of history the way copying it + /// from the bar does. Off by default: history has always been ordered by + /// when a clip was copied, and the quick-paste numbers would shift on + /// every paste. + var promoteOnPaste: Bool { + didSet { guard isLoaded else { return }; d.set(promoteOnPaste, forKey: Keys.promoteOnPaste) } + } + var ignoreConcealed: Bool { didSet { guard isLoaded else { return }; d.set(ignoreConcealed, forKey: Keys.ignoreConcealed) } } + /// Deleted clips are purged at once instead of staying restorable with + /// Command-Z for five minutes. + var deletePermanently: Bool { + didSet { guard isLoaded else { return }; d.set(deletePermanently, forKey: Keys.deletePermanently) } + } + /// 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. @@ -168,12 +323,19 @@ final class Settings { var barHeight: Double { didSet { guard isLoaded else { return } - let clamped = min(720, max(240, barHeight)) - if clamped != barHeight { barHeight = clamped; return } + let normalized = BarResizeGeometry.normalizedPersistedHeight(barHeight) + if normalized != barHeight { barHeight = normalized; return } d.set(barHeight, forKey: Keys.barHeight) } } + var showBarResizeHandle: Bool { + didSet { + guard isLoaded else { return } + d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) + } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -194,6 +356,18 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(cloudKitSync, forKey: Keys.cloudKitSync) } } + 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 selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + private init() { d.register(defaults: [ Keys.historyLimit: 500, @@ -207,18 +381,27 @@ final class Settings { Keys.hideOnClickOutside: true, Keys.pasteDirectly: true, Keys.playSound: false, + // Off by default, matching the paste sound: existing users + // shouldn't gain a new audible behavior from an update. + Keys.playSoundOnCopy: false, + Keys.promoteOnPaste: false, Keys.ignoreConcealed: true, + Keys.deletePermanently: false, Keys.ignoredSourceAppBundleIDs: [], - Keys.barHeight: 430.0, + Keys.barHeight: BarResizeGeometry.defaultHeight, + Keys.showBarResizeHandle: false, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, - Keys.cloudKitSync: true + Keys.cloudKitSync: true, + Keys.clipColorTheme: ClipColorTheme.default.rawValue, + Keys.clipColorAccentHex: "#FF5A9F", + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue ]) historyLimit = d.integer(forKey: Keys.historyLimit) historyRetentionMode = HistoryRetentionMode(rawValue: d.string(forKey: Keys.historyRetentionMode) ?? "") ?? .itemCount - historyRetentionDays = max(1, d.integer(forKey: Keys.historyRetentionDays)) + historyRetentionDays = max(0, d.integer(forKey: Keys.historyRetentionDays)) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) quickPasteModifier = d.integer(forKey: Keys.quickPasteModifier) @@ -227,15 +410,27 @@ final class Settings { hideOnClickOutside = d.bool(forKey: Keys.hideOnClickOutside) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) + playSoundOnCopy = d.bool(forKey: Keys.playSoundOnCopy) + promoteOnPaste = d.bool(forKey: Keys.promoteOnPaste) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) + deletePermanently = d.bool(forKey: Keys.deletePermanently) ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) .filter { !$0.isEmpty } - barHeight = d.double(forKey: Keys.barHeight) + let storedBarHeight = d.double(forKey: Keys.barHeight) + let normalizedBarHeight = BarResizeGeometry.normalizedPersistedHeight(storedBarHeight) + barHeight = normalizedBarHeight + showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) cloudKitSync = d.bool(forKey: Keys.cloudKitSync) + clipColorTheme = ClipColorTheme(rawValue: d.integer(forKey: Keys.clipColorTheme)) ?? .default + clipColorAccentHex = d.string(forKey: Keys.clipColorAccentHex) ?? "#FF5A9F" + selectedClipPosition = SelectedClipPosition(rawValue: d.integer(forKey: Keys.selectedClipPosition)) ?? .center isLoaded = true + if normalizedBarHeight != storedBarHeight { + d.set(normalizedBarHeight, forKey: Keys.barHeight) + } } var hotkeyDisplay: String { diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..001cd03 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -132,20 +132,66 @@ private struct GeneralSettings: View { Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) + Toggle("Promote pasted clips to the top of history", isOn: $settings.promoteOnPaste) Toggle("Play sound on paste", isOn: $settings.playSound) + Toggle("Play sound when copying from Pesty", isOn: $settings.playSoundOnCopy) Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) Toggle("Launch at login", isOn: $settings.launchAtLogin) Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") + LabeledContent("Bar height", value: "\(Int(settings.barHeight)) pt") Slider(value: $settings.barHeight, in: 300...720, step: 10) } + Toggle("Show resize handle on the Paste Bar", isOn: $settings.showBarResizeHandle) #if MAS Text("Select a clip to copy it, then press ⌘V to paste it into your app.") .font(.caption).foregroundStyle(.secondary) #endif } + Section("Clip Colors") { + Picker("Color theme", selection: $settings.clipColorTheme) { + ForEach(ClipColorTheme.allCases) { theme in + Text(theme.title).tag(theme) + } + } + .pickerStyle(.segmented) + Text(settings.clipColorTheme.detail) + .font(.caption) + .foregroundStyle(.secondary) + if settings.clipColorTheme == .accentShades { + ColorPicker("Base color", selection: clipColorAccent, supportsOpacity: false) + HStack(spacing: 12) { + Text("Preview") + .foregroundStyle(.secondary) + 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") + } + 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) + } + } + + Section("Clip Navigation") { + Picker("Selected clip position", selection: $settings.selectedClipPosition) { + ForEach(SelectedClipPosition.allCases) { position in + Text(position.title).tag(position) + } + } + .pickerStyle(.segmented) + Text(settings.selectedClipPosition.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + #if MAS Section("Sync") { Toggle("Sync history with iCloud", isOn: Binding( @@ -200,6 +246,10 @@ private struct GeneralSettings: View { #endif Section("Data") { + Toggle("Delete permanently", isOn: $settings.deletePermanently) + Text("Skips the five-minute Undo window: deleted clips are removed at once and cannot be recovered. Hold Option while deleting to skip Undo for one deletion, whatever this setting says.") + .font(.caption) + .foregroundStyle(.secondary) Button("Clear Clipboard History", role: .destructive) { ClipboardStore.shared.clearHistory() } @@ -223,6 +273,13 @@ private struct GeneralSettings: View { } #endif + private var clipColorAccent: Binding { + Binding( + get: { Color(hex: settings.clipColorAccentHex) ?? .pink }, + set: { settings.clipColorAccentHex = NSColor($0).hexString } + ) + } + private func modifierPicker(selection: Binding) -> some View { Picker("", selection: selection) { ForEach(ShortcutModifier.allCases) { modifier in @@ -242,14 +299,45 @@ private struct HistoryRetentionSettings: View { @State private var draftDays = Settings.shared.historyRetentionDays @State private var pendingRemovalCount = 0 @State private var confirmingChange = false + @State private var storageBytes: Int64? + + @State private var sliderValue = HistoryRetentionPreset(nearestDays: Settings.shared.historyRetentionDays).sliderIndex + @State private var sliderEditing = false - private static let dayChoices: [(days: Int, label: String)] = [ - (1, "1 Day"), (7, "1 Week"), (14, "2 Weeks"), (30, "1 Month"), - (90, "3 Months"), (180, "6 Months"), (365, "1 Year") - ] + private var sliderPreset: HistoryRetentionPreset { HistoryRetentionPreset(sliderIndex: sliderValue) } + + private var storageSummary: String { + let store = ClipboardStore.shared + let count = store.history.count + store.pinboards.reduce(0) { $0 + $1.items.count } + let clips = "\(count) clip\(count == 1 ? "" : "s")" + guard let storageBytes else { return clips } + return "\(clips) · \(ByteCountFormatter.string(fromByteCount: storageBytes, countStyle: .file))" + } + + private func refreshStorageSize() async { + let dir = ClipboardStore.shared.dataDirectory + storageBytes = await Task.detached(priority: .utility) { + Self.directorySize(at: dir) + }.value + } + + /// Walks the store directory off the main actor; images can make it + /// large enough that a synchronous walk would hitch the Settings window. + nonisolated private static func directorySize(at url: URL) -> Int64 { + let keys: Set = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey, .isRegularFileKey] + guard let enumerator = FileManager.default.enumerator(at: url, + includingPropertiesForKeys: Array(keys)) else { return 0 } + var total: Int64 = 0 + for case let file as URL in enumerator { + guard let values = try? file.resourceValues(forKeys: keys), + values.isRegularFile == true else { continue } + total += Int64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0) + } + return total + } var body: some View { - Section("History") { + Section { Picker("Limit history by", selection: $draftMode) { ForEach(HistoryRetentionMode.allCases) { mode in Text(mode.title).tag(mode) @@ -260,20 +348,55 @@ private struct HistoryRetentionSettings: View { Stepper(value: $draftLimit, in: 50...5000, step: 50) { LabeledContent("Keep at most", value: "\(draftLimit) clips") } - } else { - Picker("Remove clips older than", selection: $draftDays) { - ForEach(Self.dayChoices, id: \.days) { choice in - Text(choice.label).tag(choice.days) + } + } header: { + Text("History") + } footer: { + // A grouped Form lays Section body rows out on a shared label/control + // grid that clamps a bare Slider; the footer is plain full-width content. + if draftMode != .itemCount { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Remove clips older than") + Spacer() + Text(sliderPreset.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: $sliderValue, + in: 0...Double(HistoryRetentionPreset.allCases.count - 1), + step: 1, + onEditingChanged: { editing in + sliderEditing = editing + if !editing { applySliderValue() } + }) + HStack(spacing: 0) { + ForEach(HistoryRetentionPreset.allCases) { preset in + Text(preset.shortTitle) + .font(.system(size: 10, weight: preset == sliderPreset ? .bold : .medium)) + .foregroundStyle(preset == sliderPreset ? Color.accentColor : .secondary) + .frame(maxWidth: .infinity) + } } } + .padding(.vertical, 2) } Text(footnote) .font(.caption) .foregroundStyle(.secondary) + .padding(.top, draftMode == .itemCount ? 0 : 4) + LabeledContent("History and pinboards on disk", value: storageSummary) + } + .task { + await refreshStorageSize() + for await _ in NotificationCenter.default.notifications(named: .pestyStoreDidSave) { + await refreshStorageSize() + } } .onChange(of: draftMode) { evaluateDraft() } .onChange(of: draftLimit) { evaluateDraft() } .onChange(of: draftDays) { evaluateDraft() } + .onChange(of: sliderValue) { if !sliderEditing { applySliderValue() } } .alert("Remove \(pendingRemovalCount) Clips?", isPresented: $confirmingChange) { Button("Remove \(pendingRemovalCount) Clips", role: .destructive) { commit() } Button("Cancel", role: .cancel) { revert() } @@ -312,6 +435,12 @@ private struct HistoryRetentionSettings: View { draftMode = settings.historyRetentionMode draftLimit = settings.historyLimit draftDays = settings.historyRetentionDays + sliderValue = HistoryRetentionPreset(nearestDays: draftDays).sliderIndex + } + + private func applySliderValue() { + let days = HistoryRetentionPreset(sliderIndex: sliderValue).days + if days != draftDays { draftDays = days } } } diff --git a/Sources/Pesty/Store/ClipUndoStack.swift b/Sources/Pesty/Store/ClipUndoStack.swift new file mode 100644 index 0000000..7e42d0c --- /dev/null +++ b/Sources/Pesty/Store/ClipUndoStack.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Where a deleted clip sat, so Undo puts it back there instead of at the +/// front: after its predecessor if that clip is still around, else before +/// its successor, else at the original index. +struct ClipPlacement { + let container: BarSource + let index: Int + let item: ClipItem + let predecessorID: UUID? + let successorID: UUID? + + func restorationIndex(in items: [ClipItem]) -> Int { + if let predecessorID, let i = items.firstIndex(where: { $0.id == predecessorID }) { return i + 1 } + if let successorID, let i = items.firstIndex(where: { $0.id == successorID }) { return i } + return min(max(0, index), items.count) + } +} + +/// One delete operation, restorable as a whole: a bulk delete comes back +/// with a single Undo. +struct ClipDeletion { + let placements: [ClipPlacement] + let deletedAt: Date +} + +/// The deletions still open for Undo. Memory only: deleted content is never +/// written back to store.json, and nothing here reaches sync. Entries are +/// pushed in time order, so the last one is the newest and the first one +/// expires first. +struct ClipUndoStack { + static let window: TimeInterval = 5 * 60 + + private(set) var deletions: [ClipDeletion] = [] + + var isEmpty: Bool { deletions.isEmpty } + + var nextExpiration: Date? { + deletions.first.map { $0.deletedAt.addingTimeInterval(Self.window) } + } + + mutating func push(_ placements: [ClipPlacement], at date: Date) { + guard !placements.isEmpty else { return } + deletions.append(ClipDeletion(placements: placements, deletedAt: date)) + } + + /// The newest deletion, if its window is still open. + mutating func pop(at date: Date) -> ClipDeletion? { + guard let last = deletions.last, !Self.isExpired(last, at: date) else { return nil } + return deletions.removeLast() + } + + /// Drops every deletion whose window has closed and hands them back so + /// the image files they kept alive can be removed. + mutating func removeExpired(at date: Date) -> [ClipDeletion] { + let expired = deletions.filter { Self.isExpired($0, at: date) } + deletions.removeAll { Self.isExpired($0, at: date) } + return expired + } + + mutating func removeAll() -> [ClipDeletion] { + defer { deletions.removeAll() } + return deletions + } + + func retainsImageFile(named name: String) -> Bool { + deletions.contains { $0.placements.contains { $0.item.imageFileName == name } } + } + + private static func isExpired(_ deletion: ClipDeletion, at date: Date) -> Bool { + date.timeIntervalSince(deletion.deletedAt) >= window + } +} diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index c0bcd40..0bb0392 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -10,6 +10,15 @@ enum BarSource: Equatable { case pinboard(UUID) } +/// Whether keyboard focus currently belongs to the native search field or +/// to the clip strip. Lets the key monitor and the search field agree on +/// who owns a given keystroke without probing AppKit's first-responder +/// chain from every call site. +enum BarInputMode: Equatable { + case cards + case search +} + @Observable @MainActor final class ClipboardStore { @@ -18,9 +27,15 @@ final class ClipboardStore { private(set) var history: [ClipItem] = [] private(set) var pinboards: [Pinboard] = [] + private var undoStack = ClipUndoStack() + /// True while a deletion can still be undone; drives the bar's Undo button. + private(set) var hasUndoableDeletion = false + private var undoExpirationWorkItem: DispatchWorkItem? + var source: BarSource = .history { didSet { if source != oldValue { clearMultiSelection() } } } + var barInputMode: BarInputMode = .cards var searchText: String = "" { didSet { if searchText != oldValue { clearMultiSelection() } } } @@ -66,6 +81,10 @@ final class ClipboardStore { var iCloudAvailable: Bool { ClipboardStore.iCloudBase != nil } + /// The on-disk store root (history JSON plus saved images), exposed so + /// Settings can report how much space history actually uses. + var dataDirectory: URL { baseDir } + private init() { let base = (Settings.shared.iCloudSync ? ClipboardStore.iCloudBase : nil) ?? ClipboardStore.localBase baseDir = base @@ -123,6 +142,22 @@ final class ClipboardStore { scheduleSave() } + /// A Copy from the Paste Bar is an intentional use of an existing clip. + /// Promote it explicitly because the clipboard monitor correctly ignores + /// Pesty's own pasteboard writes, so a plain re-copy would otherwise leave + /// the clip sitting wherever it already was in history. + func promoteCopiedItem(_ item: ClipItem, at date: Date = .now) { + if history.contains(where: { $0.sameContent(as: item) }) { + var copied = item + copied.createdAt = date + addCaptured(copied) + return + } + // A Pinboard card with no history twin: history gets its own copy, as + // it would from any other app's copy, never the pinboard's UUID. + addCaptured(independentCopy(of: item, createdAt: date)) + } + func applyRetentionPolicy() { trimHistory(); scheduleSave() } func retentionRemovalCount(mode: HistoryRetentionMode, limit: Int, days: Int) -> Int { @@ -130,7 +165,10 @@ final class ClipboardStore { case .itemCount: return max(0, history.count - max(20, limit)) case .timeInterval: - let cutoff = Self.retentionCutoff(daysAgo: days) + // days == 0 means "forever" - no age-based cutoff, just the safety cap. + guard let cutoff = Self.retentionCutoff(daysAgo: days) else { + return max(0, history.count - Self.timeRetentionSafetyCap) + } let byAge = history.filter { $0.createdAt < cutoff }.count return byAge + max(0, (history.count - byAge) - Self.timeRetentionSafetyCap) } @@ -138,8 +176,9 @@ final class ClipboardStore { private static let timeRetentionSafetyCap = 5000 - private static func retentionCutoff(daysAgo days: Int) -> Date { - Date().addingTimeInterval(-TimeInterval(max(1, days)) * 86_400) + private static func retentionCutoff(daysAgo days: Int) -> Date? { + guard days > 0 else { return nil } + return Date().addingTimeInterval(-TimeInterval(days) * 86_400) } private(set) var retentionPrunedRecordNames: Set = [] @@ -157,11 +196,13 @@ final class ClipboardStore { history.removeLast(history.count - historyLimit) } case .timeInterval: - let cutoff = Self.retentionCutoff(daysAgo: Settings.shared.historyRetentionDays) - let old = history.filter { $0.createdAt < cutoff } - if !old.isEmpty { - removed += old - history.removeAll { $0.createdAt < cutoff } + // days == 0 means "forever" - skip the age cutoff, keep the safety cap. + if let cutoff = Self.retentionCutoff(daysAgo: Settings.shared.historyRetentionDays) { + let old = history.filter { $0.createdAt < cutoff } + if !old.isEmpty { + removed += old + history.removeAll { $0.createdAt < cutoff } + } } if history.count > Self.timeRetentionSafetyCap { removed += Array(history[Self.timeRetentionSafetyCap...]) @@ -190,26 +231,35 @@ final class ClipboardStore { /// Deleting exactly what was removed also closes an image-file leak: the /// old cross-container removal deleted entries under two file names but /// cleaned up only one of them. - func delete(_ item: ClipItem) { delete(items: [item]) } + func delete(_ item: ClipItem, permanently: Bool = false) { + delete(items: [item], permanently: permanently) + } - func delete(items: [ClipItem]) { + /// A deletion stays undoable for five minutes unless `permanently` is + /// set for this one (Option was held) or the setting says so for all. + func delete(items: [ClipItem], permanently: Bool = false) { let ids = Set(items.map(\.id)) guard !ids.isEmpty else { return } // Captured before removal so repeated deletes walk down the list // instead of snapping back to the newest clip every time. let deletedIndex = visibleItems.firstIndex(where: { ids.contains($0.id) }) let selectionDeleted = selectedID.map { ids.contains($0) } ?? false - let removed: [ClipItem] + let placements: [ClipPlacement] switch source { case .history: - removed = history.filter { ids.contains($0.id) } + placements = Self.placements(of: ids, in: history, container: .history) history.removeAll { ids.contains($0.id) } case .pinboard(let boardID): guard let boardIndex = pinboards.firstIndex(where: { $0.id == boardID }) else { return } - removed = pinboards[boardIndex].items.filter { ids.contains($0.id) } + placements = Self.placements(of: ids, in: pinboards[boardIndex].items, container: source) pinboards[boardIndex].items.removeAll { ids.contains($0.id) } } - for entry in removed { deleteImageFile(entry) } + if permanently || Settings.shared.deletePermanently { + for placement in placements { deleteImageFile(placement.item) } + } else { + undoStack.push(placements, at: Date()) + refreshUndoState() + } multiSelectedIDs.subtract(ids) if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } if selectionDeleted { @@ -229,11 +279,94 @@ final class ClipboardStore { let old = history history.removeAll() selectedID = nil + forgetDeletions() for item in old { deleteImageFile(item) } reconcileMultiSelection() scheduleSave() } + private static func placements(of ids: Set, in items: [ClipItem], + container: BarSource) -> [ClipPlacement] { + items.indices.compactMap { index in + guard ids.contains(items[index].id) else { return nil } + return ClipPlacement(container: container, + index: index, + item: items[index], + predecessorID: index > 0 ? items[index - 1].id : nil, + successorID: index + 1 < items.count ? items[index + 1].id : nil) + } + } + + /// Puts the newest deletion back where it was, every clip of a bulk + /// delete at once, while its five-minute window is open. + @discardableResult + func undoLastDelete() -> Bool { + refreshUndoState() + guard let deletion = undoStack.pop(at: Date()) else { return false } + var restored: [UUID] = [] + for placement in deletion.placements.sorted(by: { $0.index < $1.index }) { + switch placement.container { + case .history: + if restore(placement, into: &history) { restored.append(placement.item.id) } + case .pinboard(let boardID): + guard let i = pinboards.firstIndex(where: { $0.id == boardID }) else { + deleteImageFile(placement.item) + continue + } + if restore(placement, into: &pinboards[i].items) { restored.append(placement.item.id) } + } + } + refreshUndoState() + if let id = restored.first(where: { id in visibleItems.contains(where: { $0.id == id }) }) { + selectedID = id + selectionAnchorID = id + } + reconcileMultiSelection() + scheduleSave() + return true + } + + /// A clip that came back by other means in the meantime (copied again, + /// or synced in) is left as it is rather than doubled. + private func restore(_ placement: ClipPlacement, into items: inout [ClipItem]) -> Bool { + let item = placement.item + if items.contains(where: { $0.id == item.id || $0.sameContent(as: item) }) { + deleteImageFile(item) + return false + } + items.insert(item, at: placement.restorationIndex(in: items)) + return true + } + + /// Drops deletions whose window has closed, releasing the image files + /// they kept, and arms a timer for the next one so the Undo button + /// retires on time. + private func refreshUndoState() { + let now = Date() + for deletion in undoStack.removeExpired(at: now) { + for placement in deletion.placements { deleteImageFile(placement.item) } + } + hasUndoableDeletion = !undoStack.isEmpty + undoExpirationWorkItem?.cancel() + undoExpirationWorkItem = nil + guard let expiration = undoStack.nextExpiration else { return } + let work = DispatchWorkItem { [weak self] in self?.refreshUndoState() } + undoExpirationWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, expiration.timeIntervalSince(now)), + execute: work) + } + + /// Clear History and removing a Pinboard are the moments deleted content + /// is expected to be gone for good, so nothing stays restorable. Quitting + /// forgets as well, since the stack does not outlive the process and the + /// image files it kept would otherwise be orphaned. + func forgetDeletions() { + for deletion in undoStack.removeAll() { + for placement in deletion.placements { deleteImageFile(placement.item) } + } + refreshUndoState() + } + @discardableResult func addPinboard(name: String, colorHex: String = "#5B8DEF") -> Pinboard { let b = Pinboard(name: name, colorHex: colorHex) @@ -253,6 +386,7 @@ final class ClipboardStore { if case .pinboard(let cur) = source, cur == id { source = .history } let removedItems = pinboards[i].items pinboards.remove(at: i) + forgetDeletions() for item in removedItems { deleteImageFile(item) } reconcileMultiSelection() scheduleSave() @@ -261,11 +395,18 @@ final class ClipboardStore { func saveToPinboard(_ item: ClipItem, boardID: UUID) { guard let i = pinboards.firstIndex(where: { $0.id == boardID }) else { return } if pinboards[i].items.contains(where: { $0.sameContent(as: item) }) { return } - // Pinboard copies mint their own UUID (one sync record per container). + pinboards[i].items.insert(independentCopy(of: item, createdAt: item.createdAt), at: 0) + scheduleSave() + } + + /// A copy with its own UUID and its own image file, so no two containers + /// ever share an identity (one sync record per container) or a file. + private func independentCopy(of item: ClipItem, createdAt: Date) -> ClipItem { var copy = ClipItem( type: item.type, text: item.text, rtfData: item.rtfData, + htmlData: item.htmlData, imageFileName: item.imageFileName, imageHash: item.imageHash, fileURLs: item.fileURLs, @@ -273,10 +414,9 @@ final class ClipboardStore { sourceBundleID: item.sourceBundleID, sourceAppName: item.sourceAppName, customTitle: item.customTitle, - createdAt: item.createdAt) + createdAt: createdAt) if let dup = duplicateImageFile(item) { copy.imageFileName = dup } - pinboards[i].items.insert(copy, at: 0) - scheduleSave() + return copy } func item(withID id: UUID) -> ClipItem? { @@ -294,6 +434,7 @@ final class ClipboardStore { updated.type = type updated.text = text updated.rtfData = richTextData + updated.htmlData = nil updated.colorHex = nil return updated } @@ -308,6 +449,7 @@ final class ClipboardStore { updated.type = .color updated.text = nil updated.rtfData = nil + updated.htmlData = nil updated.colorHex = normalized return updated } @@ -500,6 +642,7 @@ 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 } } + || undoStack.retainsImageFile(named: name) if stillUsed { return } if let url = imageURL(for: item) { try? FileManager.default.removeItem(at: url) } } diff --git a/Sources/Pesty/UI/BarResizeGeometry.swift b/Sources/Pesty/UI/BarResizeGeometry.swift new file mode 100644 index 0000000..063418f --- /dev/null +++ b/Sources/Pesty/UI/BarResizeGeometry.swift @@ -0,0 +1,116 @@ +import CoreGraphics + +/// Pure sizing rules shared by the Paste Bar's persisted preference and its +/// live-resize interaction. +enum BarResizeGeometry { + static let defaultHeight = 430.0 + static let minimumHeight = 300.0 + static let maximumHeight = 720.0 + + /// Returns a value which is safe to persist independently of the display + /// the bar happens to be shown on. + static func normalizedPersistedHeight(_ height: Double) -> Double { + guard height.isFinite else { return defaultHeight } + return min(maximumHeight, max(minimumHeight, height)) + } + + /// Applies the portable preference range first, then caps the result to + /// the usable height of the target display. + static func effectiveHeight( + for requestedHeight: Double, + in visibleFrame: CGRect + ) -> CGFloat { + let displayHeight = visibleFrame.height + guard displayHeight.isFinite, displayHeight > 0 else { return 0 } + return min(CGFloat(normalizedPersistedHeight(requestedHeight)), displayHeight) + } + + /// Produces the complete bottom-docked panel frame for a target display. + static func panelFrame( + for requestedHeight: Double, + in visibleFrame: CGRect + ) -> CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: effectiveHeight(for: requestedHeight, in: visibleFrame) + ) + } + + /// The macOS 26 glass surface extends below the panel so its lower corners + /// remain clipped. Live resizing must preserve that oversized root view. + static func presentedContentFrame( + panelSize: CGSize, + bottomExtension: CGFloat + ) -> CGRect { + let extensionHeight = max(0, bottomExtension) + return CGRect( + x: 0, + y: -extensionHeight, + width: panelSize.width, + height: panelSize.height + extensionHeight + ) + } +} + +/// Immutable state captured at mouse-down so live resizing does not feed the +/// moving panel geometry back into the drag calculation. +struct BarResizeSession: Equatable { + let initialPanelHeight: CGFloat + let initialScreenY: CGFloat + let visibleFrame: CGRect + + init( + initialPanelHeight: CGFloat, + initialScreenY: CGFloat, + visibleFrame: CGRect + ) { + self.visibleFrame = visibleFrame + self.initialPanelHeight = BarResizeGeometry.effectiveHeight( + for: Double(initialPanelHeight), + in: visibleFrame + ) + self.initialScreenY = initialScreenY + } + + /// Global macOS screen coordinates increase upward, so upward pointer + /// travel adds to the bar height and downward travel subtracts from it. + func height(atScreenY screenY: CGFloat) -> CGFloat { + guard initialScreenY.isFinite, screenY.isFinite else { + return initialPanelHeight + } + let requestedHeight = initialPanelHeight + (screenY - initialScreenY) + return BarResizeGeometry.effectiveHeight( + for: Double(requestedHeight), + in: visibleFrame + ) + } + + func frame(atScreenY screenY: CGFloat) -> CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: height(atScreenY: screenY) + ) + } + + /// The portable value to write only after a successful drag completes. + func finalPersistedHeight(atScreenY screenY: CGFloat) -> Double { + BarResizeGeometry.normalizedPersistedHeight( + Double(height(atScreenY: screenY)) + ) + } + + var cancelledHeight: CGFloat { initialPanelHeight } + + var cancelledFrame: CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: initialPanelHeight + ) + } +} diff --git a/Sources/Pesty/UI/BarResizeHandle.swift b/Sources/Pesty/UI/BarResizeHandle.swift new file mode 100644 index 0000000..b05bb87 --- /dev/null +++ b/Sources/Pesty/UI/BarResizeHandle.swift @@ -0,0 +1,222 @@ +import AppKit +import Carbon.HIToolbox +import SwiftUI + +/// Lets the Paste Bar's local key monitor leave Escape and drag-time keys to +/// the handle while it temporarily owns first-responder status. +protocol BarResizeHandleResponder: AnyObject {} + +/// A compact drag target for resizing the Paste Bar vertically. +/// +/// Drag callbacks use AppKit's global screen coordinate space so the owner can +/// keep its resize geometry independent of the handle's position in SwiftUI. +@MainActor +struct BarResizeHandle: View { + private static let size = NSSize(width: 42, height: 14) + + private let accessibilityValue: CGFloat? + private let onBegin: (NSPoint) -> Void + private let onChange: (NSPoint) -> Void + private let onEnd: (NSPoint) -> Void + private let onCancel: () -> Void + private let onAccessibilityAdjustment: (CGFloat) -> Void + + init( + accessibilityValue: CGFloat? = nil, + onBegin: @escaping (NSPoint) -> Void, + onChange: @escaping (NSPoint) -> Void, + onEnd: @escaping (NSPoint) -> Void, + onCancel: @escaping () -> Void, + onAccessibilityAdjustment: @escaping (CGFloat) -> Void + ) { + self.accessibilityValue = accessibilityValue + self.onBegin = onBegin + self.onChange = onChange + self.onEnd = onEnd + self.onCancel = onCancel + self.onAccessibilityAdjustment = onAccessibilityAdjustment + } + + var body: some View { + BarResizeHandleRepresentable( + accessibilityValue: accessibilityValue, + callbacks: .init( + onBegin: onBegin, + onChange: onChange, + onEnd: onEnd, + onCancel: onCancel, + onAccessibilityAdjustment: onAccessibilityAdjustment)) + .frame(width: Self.size.width, height: Self.size.height) + } +} + +@MainActor +private struct BarResizeHandleRepresentable: NSViewRepresentable { + let accessibilityValue: CGFloat? + let callbacks: BarResizeHandleView.Callbacks + + func makeNSView(context: Context) -> BarResizeHandleView { + BarResizeHandleView(callbacks: callbacks, accessibilityValue: accessibilityValue) + } + + func updateNSView(_ nsView: BarResizeHandleView, context: Context) { + nsView.callbacks = callbacks + nsView.accessibilityHeight = accessibilityValue + } +} + +@MainActor +private final class BarResizeHandleView: NSView, BarResizeHandleResponder { + static let accessibilityStep: CGFloat = 10 + + struct Callbacks { + let onBegin: (NSPoint) -> Void + let onChange: (NSPoint) -> Void + let onEnd: (NSPoint) -> Void + let onCancel: () -> Void + let onAccessibilityAdjustment: (CGFloat) -> Void + } + + var callbacks: Callbacks + var accessibilityHeight: CGFloat? { + didSet { updateAccessibilityValue() } + } + + private var isTrackingDrag = false + private var ownsResizeCursor = false + private weak var previousFirstResponder: NSResponder? + + override var acceptsFirstResponder: Bool { true } + override var intrinsicContentSize: NSSize { NSSize(width: 42, height: 14) } + + init(callbacks: Callbacks, accessibilityValue: CGFloat?) { + self.callbacks = callbacks + accessibilityHeight = accessibilityValue + super.init(frame: NSRect(x: 0, y: 0, width: 42, height: 14)) + + setAccessibilityElement(true) + setAccessibilityRole(.slider) + setAccessibilityLabel("Resize Paste Bar") + setAccessibilityHelp("Drag vertically, or increment and decrement in ten-point steps.") + updateAccessibilityValue() + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) unavailable") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let line = NSRect(x: bounds.midX - 21, y: bounds.midY - 2, width: 42, height: 4) + NSColor.secondaryLabelColor.withAlphaComponent(0.55).setFill() + NSBezierPath(roundedRect: line, xRadius: 2, yRadius: 2).fill() + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .resizeUpDown) + } + + override func mouseDown(with event: NSEvent) { + guard !isTrackingDrag else { return } + isTrackingDrag = true + previousFirstResponder = window?.firstResponder + window?.makeFirstResponder(self) + NSCursor.resizeUpDown.push() + ownsResizeCursor = true + callbacks.onBegin(screenPoint(for: event)) + } + + override func mouseDragged(with event: NSEvent) { + guard isTrackingDrag else { return } + callbacks.onChange(screenPoint(for: event)) + } + + override func mouseUp(with event: NSEvent) { + guard isTrackingDrag else { return } + let point = screenPoint(for: event) + finishTracking() + callbacks.onEnd(point) + } + + override func keyDown(with event: NSEvent) { + guard isTrackingDrag, Int(event.keyCode) == kVK_Escape else { + super.keyDown(with: event) + return + } + cancelTracking() + } + + override func cancelOperation(_ sender: Any?) { + guard isTrackingDrag else { + super.cancelOperation(sender) + return + } + cancelTracking() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if isTrackingDrag, newWindow !== window { + cancelTracking() + } + super.viewWillMove(toWindow: newWindow) + } + + override func accessibilityPerformIncrement() -> Bool { + callbacks.onAccessibilityAdjustment(Self.accessibilityStep) + return true + } + + override func accessibilityPerformDecrement() -> Bool { + callbacks.onAccessibilityAdjustment(-Self.accessibilityStep) + return true + } + + @objc private func windowDidResignKey(_ notification: Notification) { + guard isTrackingDrag, + let notificationWindow = notification.object as? NSWindow, + notificationWindow === window else { return } + cancelTracking() + } + + private func cancelTracking() { + guard isTrackingDrag else { return } + finishTracking() + callbacks.onCancel() + } + + private func finishTracking() { + isTrackingDrag = false + if ownsResizeCursor { + NSCursor.pop() + ownsResizeCursor = false + } + // If the previous responder refuses, the window itself takes over; + // the handle must never keep the bar's keys after a drag. + if let window, window.firstResponder === self, + !window.makeFirstResponder(previousFirstResponder) { + window.makeFirstResponder(nil) + } + previousFirstResponder = nil + } + + private func screenPoint(for event: NSEvent) -> NSPoint { + guard let eventWindow = event.window else { return NSEvent.mouseLocation } + return eventWindow.convertPoint(toScreen: event.locationInWindow) + } + + private func updateAccessibilityValue() { + let value = accessibilityHeight.map { NSNumber(value: Double($0)) } + setAccessibilityValue(value) + } +} diff --git a/Sources/Pesty/UI/BarSearchField.swift b/Sources/Pesty/UI/BarSearchField.swift new file mode 100644 index 0000000..eba542b --- /dev/null +++ b/Sources/Pesty/UI/BarSearchField.swift @@ -0,0 +1,157 @@ +import AppKit +import SwiftUI + +/// Keeps the Paste Bar's native search field reachable while SwiftUI renders +/// it at a compact width. The local key monitor can therefore transfer first +/// responder synchronously and let the triggering key reach AppKit normally. +@MainActor +final class BarSearchFieldBridge { + weak var field: NSTextField? + + func install(_ field: NSTextField) { + self.field = field + } + + func uninstall(_ field: NSTextField) { + if self.field === field { self.field = nil } + } + + @discardableResult + func focusAtEnd() -> Bool { + guard let field, + let window = field.window, + window.makeFirstResponder(field) else { return false } + if let editor = field.currentEditor() as? NSTextView { + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + } + return true + } + + func resign() { + guard let field, + let window = field.window, + let editor = field.currentEditor(), + window.firstResponder === editor else { return } + window.makeFirstResponder(nil) + } + + func ownsFirstResponder(in window: NSWindow?) -> Bool { + guard let window, + let field, + let editor = field.currentEditor() else { return false } + return window.firstResponder === editor + } +} + +struct NativeBarSearchField: NSViewRepresentable { + @Binding var text: String + let bridge: BarSearchFieldBridge + let onBegin: () -> Void + let onEnd: () -> Void + let onSubmit: () -> Void + let onCancel: () -> Void + let onMoveSelection: (Int) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.delegate = context.coordinator + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + field.isEditable = true + field.isSelectable = true + field.usesSingleLineMode = true + field.lineBreakMode = .byTruncatingTail + field.placeholderString = "Search" + field.font = .systemFont(ofSize: 13, weight: .medium) + // Matches Theme.chromeTextPrimary (Color.white.opacity(0.95)). + field.textColor = NSColor.white.withAlphaComponent(0.95) + let placeholderColor = NSColor.white.withAlphaComponent(0.55) + field.placeholderAttributedString = NSAttributedString( + string: "Search", + attributes: [.foregroundColor: placeholderColor] + ) + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + bridge.install(field) + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + context.coordinator.parent = self + bridge.install(field) + // Reassigning an equal value resets the native caret and selection. + if field.stringValue != text { field.stringValue = text } + } + + static func dismantleNSView(_ field: NSTextField, coordinator: Coordinator) { + coordinator.parent.bridge.uninstall(field) + field.delegate = nil + } + + @MainActor + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: NativeBarSearchField + + init(parent: NativeBarSearchField) { + self.parent = parent + } + + func controlTextDidBeginEditing(_ notification: Notification) { + parent.onBegin() + } + + func controlTextDidEndEditing(_ notification: Notification) { + parent.onEnd() + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField, + parent.text != field.stringValue else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector) -> Bool { + if textView.hasMarkedText() { return false } + + if commandSelector == #selector(NSResponder.insertNewline(_:)) + || commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) { + parent.onSubmit() + return true + } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + parent.onCancel() + return true + } + // The query is one line, so vertical arrows have no caret to move + // and walk the strip instead. Horizontal arrows edit the query + // until the caret reaches an end of it, then do the same. + let caret = textView.selectedRange() + let length = (textView.string as NSString).length + if commandSelector == #selector(NSResponder.moveUp(_:)) { + parent.onMoveSelection(-1) + return true + } + if commandSelector == #selector(NSResponder.moveDown(_:)) { + parent.onMoveSelection(1) + return true + } + if commandSelector == #selector(NSResponder.moveLeft(_:)), + caret.length == 0, caret.location == 0 { + parent.onMoveSelection(-1) + return true + } + if commandSelector == #selector(NSResponder.moveRight(_:)), + caret.length == 0, caret.location == length { + parent.onMoveSelection(1) + return true + } + return false + } + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 382c5cb..f8929ea 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -1,9 +1,21 @@ import SwiftUI struct BarView: View { + let searchBridge: BarSearchFieldBridge @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + private var searchIsActive: Bool { + store.barInputMode == .search || !store.searchText.isEmpty + } + + private var searchTextBinding: Binding { + Binding( + get: { store.searchText }, + set: { AppController.shared.updateBarSearchText($0) } + ) + } + var body: some View { ZStack { panelBackground @@ -14,6 +26,17 @@ struct BarView: View { strip } } + .overlay(alignment: .top) { + if settings.showBarResizeHandle { + BarResizeHandle( + accessibilityValue: CGFloat(settings.barHeight), + onBegin: AppController.shared.beginBarResize, + onChange: AppController.shared.updateBarResize, + onEnd: AppController.shared.endBarResize, + onCancel: AppController.shared.cancelBarResize, + onAccessibilityAdjustment: AppController.shared.adjustBarHeight) + } + } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() } @@ -45,6 +68,9 @@ struct BarView: View { if store.multiSelectedIDs.count > 1 { bulkDeleteButton } + if store.hasUndoableDeletion { + undoButton + } moreMenu } .padding(.horizontal, 18) @@ -64,17 +90,36 @@ struct BarView: View { } private var searchIndicator: some View { - HStack(spacing: 6) { + HStack(spacing: searchIsActive ? 6 : 0) { Image(systemName: "magnifyingglass") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(store.searchText.isEmpty ? Theme.chromeTextSecondary : Theme.chromeTextPrimary) - if !store.searchText.isEmpty { - Text(store.searchText) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Theme.chromeTextPrimary) - .lineLimit(1) - .truncationMode(.head) - Button { store.searchText = ""; store.selectFirst() } label: { + .foregroundStyle(searchIsActive ? Theme.chromeTextPrimary : Theme.chromeTextSecondary) + .accessibilityHidden(true) + + // Kept mounted even in the compact state: the key monitor can + // focus it synchronously and return the same first key event, so + // type-anywhere search never loses a character. + NativeBarSearchField( + text: searchTextBinding, + bridge: searchBridge, + onBegin: { AppController.shared.setBarSearchEditing(true) }, + onEnd: { AppController.shared.setBarSearchEditing(false) }, + onSubmit: { AppController.shared.pasteSelected() }, + onCancel: { AppController.shared.cancelBarSearchOrHide() }, + onMoveSelection: { store.moveSelection(by: $0) } + ) + .frame(minWidth: searchIsActive ? 120 : 0, + idealWidth: searchIsActive ? 180 : 0, + maxWidth: searchIsActive ? 260 : 0, + alignment: .leading) + .opacity(searchIsActive ? 1 : 0) + .allowsHitTesting(searchIsActive) + .accessibilityHidden(!searchIsActive) + + if searchIsActive { + Button { + AppController.shared.clearBarSearch() + } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)).foregroundStyle(Theme.chromeTextTertiary) } @@ -82,11 +127,15 @@ struct BarView: View { .accessibilityLabel("Clear search") } } - .padding(.horizontal, store.searchText.isEmpty ? 0 : 10) - .frame(minWidth: 22, maxWidth: 700, minHeight: 30, maxHeight: 30, alignment: .leading) - .fixedSize(horizontal: true, vertical: false) - .background(store.searchText.isEmpty ? Color.clear : Theme.fieldBG, in: Capsule()) - .animation(.easeOut(duration: 0.15), value: store.searchText.isEmpty) + .padding(.horizontal, searchIsActive ? 10 : 0) + .frame(height: 30) + .background(searchIsActive ? Theme.fieldBG : Color.clear, in: Capsule()) + // Without this, the search field competes for space with the + // Pinboard tabs' ScrollView in the same HStack and can get squeezed + // below its intended width — an active query needs to keep its + // requested width over the (infinitely flexible) tab strip. + .layoutPriority(searchIsActive ? 2 : 0) + .animation(.easeOut(duration: 0.15), value: searchIsActive) } private var bulkDeleteButton: some View { @@ -105,6 +154,21 @@ struct BarView: View { .accessibilityLabel("Delete \(count) selected clips") } + private var undoButton: some View { + Button { + store.undoLastDelete() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + .font(.system(size: 12.5, weight: .medium)) + .lineLimit(1) + } + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + .help("Undo the last deletion (⌘Z), available for 5 minutes") + .accessibilityLabel("Undo last deletion") + } + private var moreMenu: some View { Menu { Button { AppController.shared.showSettings() } label: { @@ -166,15 +230,25 @@ struct BarView: View { } .onChange(of: store.selectedID) { _, id in guard let id else { return } - withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { - proxy.scrollTo(id, anchor: .center) - } + scrollToSelected(id, proxy: proxy) + } + .onChange(of: settings.selectedClipPosition) { _, _ in + guard let id = store.selectedID else { return } + scrollToSelected(id, proxy: proxy) } .overlay { if store.visibleItems.isEmpty { emptyState } } } .frame(maxHeight: .infinity) } + /// The anchor preference decides whether the strip parks the selected + /// card centered or against the right edge, Paste-style. + private func scrollToSelected(_ id: UUID, proxy: ScrollViewProxy) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { + proxy.scrollTo(id, anchor: settings.selectedClipPosition == .rightEdge ? .trailing : .center) + } + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: store.searchText.isEmpty ? "doc.on.clipboard" : "magnifyingglass") diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 663be06..69eaa06 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -33,6 +33,9 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { private var phase: Phase = .hidden private var epoch = 0 + private let searchBridge = BarSearchFieldBridge() + private var targetVisibleFrame: NSRect? + private var resizeSession: BarResizeSession? /// True while the bar is up or on its way up. `AppController.toggleBar` asks this /// instead of `window.isVisible`. @@ -68,7 +71,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { // Without this a stray close() would deallocate the panel and leave `window` // nil, which is another way to never show the bar again. panel.isReleasedWhenClosed = false - let content = NSHostingView(rootView: BarView()) + let content = NSHostingView(rootView: BarView(searchBridge: searchBridge)) if #available(macOS 26.0, *) { let glassContent = NSView() content.translatesAutoresizingMaskIntoConstraints = false @@ -145,8 +148,13 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { guard let screen = Self.targetScreen() else { return } panel.level = Settings.shared.hideOnClickOutside ? .modalPanel : .floating let vf = screen.visibleFrame - let height = min(CGFloat(Settings.shared.barHeight), vf.height) - let onScreen = NSRect(x: vf.minX, y: vf.minY, width: vf.width, height: height) + let onScreen = BarResizeGeometry.panelFrame( + for: Settings.shared.barHeight, + in: vf + ) + let height = onScreen.height + targetVisibleFrame = vf + resizeSession = nil // The panel stays parked at its final frame and the content slides up *inside* // it. Animating the window frame itself is not safe on multi-display setups: @@ -184,8 +192,10 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = Self.showDuration ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - content.animator().frame = NSRect(x: 0, y: -bottomExtension, - width: onScreen.width, height: contentHeight) + content.animator().frame = BarResizeGeometry.presentedContentFrame( + panelSize: onScreen.size, + bottomExtension: bottomExtension + ) }, completionHandler: { DispatchQueue.main.async(execute: finish) }) DispatchQueue.main.asyncAfter(deadline: .now() + Self.showDuration + 0.05, execute: finish) } @@ -193,6 +203,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { func hide() { guard let panel = window, let content = panel.contentView else { return } guard isPresented else { return } + cancelBarResize() let token = beginTransition() phase = .hiding(token) @@ -218,11 +229,79 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { /// Used after sleep or a display change, where an in-flight transition can be /// left stranded on a screen that no longer exists. func forceHide() { + resizeSession = nil _ = beginTransition() phase = .hidden window?.orderOut(nil) } + var searchOwnsFirstResponder: Bool { + searchBridge.ownsFirstResponder(in: window) + } + + @discardableResult + func focusSearchAtEnd() -> Bool { + searchBridge.focusAtEnd() + } + + func resignSearch() { + searchBridge.resign() + } + + func beginBarResize(at screenPoint: NSPoint) { + guard phase == .shown, + resizeSession == nil, + let panel = window, + let visibleFrame = targetVisibleFrame else { return } + resizeSession = BarResizeSession( + initialPanelHeight: panel.frame.height, + initialScreenY: screenPoint.y, + visibleFrame: visibleFrame + ) + } + + func updateBarResize(at screenPoint: NSPoint) { + guard phase == .shown, let resizeSession else { return } + applyResizeFrame(resizeSession.frame(atScreenY: screenPoint.y)) + } + + func endBarResize(at screenPoint: NSPoint) { + guard phase == .shown, let resizeSession else { return } + let frame = resizeSession.frame(atScreenY: screenPoint.y) + let persistedHeight = resizeSession.finalPersistedHeight(atScreenY: screenPoint.y) + self.resizeSession = nil + applyResizeFrame(frame) + Settings.shared.barHeight = persistedHeight + } + + func cancelBarResize() { + guard let resizeSession else { return } + self.resizeSession = nil + guard phase == .shown else { return } + applyResizeFrame(resizeSession.cancelledFrame) + } + + func adjustBarHeight(by delta: CGFloat) { + guard phase == .shown, + resizeSession == nil, + let panel = window, + let visibleFrame = targetVisibleFrame else { return } + let requested = Double(panel.frame.height + delta) + let frame = BarResizeGeometry.panelFrame(for: requested, in: visibleFrame) + applyResizeFrame(frame) + Settings.shared.barHeight = Double(frame.height) + } + + private func applyResizeFrame(_ frame: NSRect) { + guard let panel = window, let content = panel.contentView, + frame.width > 0, frame.height > 0 else { return } + panel.setFrame(frame, display: true) + content.frame = BarResizeGeometry.presentedContentFrame( + panelSize: frame.size, + bottomExtension: Self.contentBottomExtension + ) + } + func windowDidResignKey(_ notification: Notification) { guard Settings.shared.hideOnClickOutside, phase == .shown, diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index a8b4725..7a367a6 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,4 +1,5 @@ import AppKit +import ImageIO import SwiftUI struct ClipCardView: View { @@ -7,6 +8,9 @@ struct ClipCardView: View { let selected: Bool @State private var hovering = false + @State private var preview: NSImage? + @State private var previewFailed = false + @State private var fileInfo: ClipPreviewProvider.FileInfo? private var store: ClipboardStore { ClipboardStore.shared } private var settings: Settings { Settings.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } @@ -46,6 +50,7 @@ struct ClipCardView: View { .highPriorityGesture(TapGesture().modifiers(.command).onEnded { store.toggleSelection(item.id) }) .onDrag { ClipDragProvider.make(for: item) } .contextMenu { menu } + .task(id: item.id) { await loadPreview() } } private var header: some View { @@ -53,7 +58,7 @@ struct ClipCardView: View { headerColor HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 2) { - Text(item.type.label) + Text(cardTypeLabel) .font(.system(size: 15, weight: .bold)) .foregroundStyle(Theme.headerText) Text(item.createdAt.clipRelativeLong) @@ -70,6 +75,11 @@ struct ClipCardView: View { .frame(height: Theme.headerHeight) } + private var cardTypeLabel: String { + if item.type == .file, item.fileURLs.count > 1 { return "\(item.fileURLs.count) files" } + return item.type.label + } + private var appIconTile: some View { RoundedRectangle(cornerRadius: 15, style: .continuous) .fill(Color.white.opacity(0.14)) @@ -90,24 +100,27 @@ struct ClipCardView: View { VStack(alignment: .leading, spacing: 0) { content .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, showsImage ? 0 : 13) + .padding(.top, showsImage ? 0 : 11) footer + .padding(.horizontal, 13) + .padding(.bottom, 10) } - .padding(.horizontal, 13) - .padding(.top, 11) - .padding(.bottom, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Theme.cardBody) } + /// Image clips, and file clips pointing at an image, which is how a copied + /// screenshot usually arrives, run edge to edge instead of sitting inset. + private var showsImage: Bool { + ClipPreviewProvider.showsImage(item) && !previewFailed + } + @ViewBuilder private var content: some View { switch item.type { case .image: - if let img = store.loadImage(for: item) { - Image(nsImage: img) - .resizable().interpolation(.medium).scaledToFit() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { placeholder("photo") } + imageCanvas case .color: ZStack { RoundedRectangle(cornerRadius: 8).fill(Color(hex: item.colorHex ?? "#000") ?? .black) @@ -117,14 +130,11 @@ 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.cardTextSecondary).lineLimit(2) - .multilineTextAlignment(.center) + if showsImage { + imageCanvas + } else { + fileContent } - .frame(maxWidth: .infinity, maxHeight: .infinity) case .link: VStack(spacing: 10) { Spacer(minLength: 0) @@ -142,6 +152,62 @@ struct ClipCardView: View { } } + /// Over a checkerboard, so a transparent cut-out is distinguishable from + /// an image on white. + private var imageCanvas: some View { + ZStack { + CheckerboardBackground() + if let image = preview ?? ClipPreviewProvider.cachedImage(for: item) { + Image(nsImage: image) + .resizable().interpolation(.medium).scaledToFit() + } else { + placeholder("photo") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + + @ViewBuilder + private var fileContent: some View { + VStack(spacing: 9) { + if let icon = fileInfo?.icon ?? ClipPreviewProvider.cachedIcon(for: item) { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: Theme.fileIconSize, height: Theme.fileIconSize) + .opacity(fileInfo?.isMissing == true ? 0.5 : 1) + } else { + Image(systemName: "doc.fill").font(.system(size: 32)) + .foregroundStyle(headerColor) + } + Text(item.displayTitle).font(.system(size: 12)) + .foregroundStyle(Theme.cardTextSecondary).lineLimit(2) + .multilineTextAlignment(.center) + if fileInfo?.isMissing == true { + Text("File not found") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.cardTextTertiary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadPreview() async { + preview = ClipPreviewProvider.cachedImage(for: item) + fileInfo = nil + previewFailed = false + if ClipPreviewProvider.showsImage(item) { + if preview == nil { + preview = await ClipPreviewProvider.image(for: item, scale: NSScreen.main?.backingScaleFactor ?? 2) + } + guard preview == nil, item.type == .file else { return } + previewFailed = true + } + guard item.type == .file else { return } + fileInfo = await ClipPreviewProvider.fileInfo(for: item) + } + private func placeholder(_ symbol: String) -> some View { Image(systemName: symbol).font(.system(size: 30)) .foregroundStyle(Theme.cardTextTertiary) @@ -155,11 +221,15 @@ struct ClipCardView: View { .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.cardTextPrimary).lineLimit(1) } - HStack(spacing: 6) { + HStack(alignment: .bottom, spacing: 6) { + // A path is worth reading in full, so it wraps rather than + // collapsing to an ellipsis; everything else stays one line. Text(metaLeft) .font(.system(size: 11)) .foregroundStyle(Theme.cardTextSecondary) - .lineLimit(1) + .lineLimit(item.type == .file ? 3 : 1) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) if index < 9 { HStack(spacing: 3) { @@ -175,6 +245,23 @@ struct ClipCardView: View { .padding(.top, 8) } + private var isSingleImageFile: Bool { + ClipPreviewProvider.imageFileURL(for: item) != nil + } + + /// NSHomeDirectory is the sandbox container in the App Store build, so the + /// real home comes from the account record instead. + private static let homePath: String = { + if let home = getpwuid(getuid())?.pointee.pw_dir { return String(cString: home) } + return NSHomeDirectory() + }() + + private static func abbreviatedPath(_ path: String) -> String { + if path == homePath { return "~" } + guard path.hasPrefix(homePath + "/") else { return path } + return "~" + path.dropFirst(homePath.count) + } + private var metaLeft: String { switch item.type { case .text, .richText: @@ -183,9 +270,24 @@ 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")" + guard item.fileURLs.count == 1, + let url = item.fileURLs.first.flatMap(URL.init(string:)) else { + return item.fileURLs + .compactMap { URL(string: $0)?.lastPathComponent } + .joined(separator: ", ") + } + // An image shows what it is, so its size is the useful fact; a + // screenshot's path is a timestamped folder nobody reads. Files + // without a preview get the full location instead, since two + // documents of the same name differ only by where they live. + if isSingleImageFile { + guard let size = ImagePixelSize.of(url) else { return url.lastPathComponent } + return "\(Int(size.width)) × \(Int(size.height))" + } + return Self.abbreviatedPath(url.path) case .image: - return "Image" + guard let size = ImagePixelSize.of(item) else { return "Image" } + return "\(Int(size.width)) × \(Int(size.height))" case .color: return item.colorHex ?? "Color" } @@ -197,11 +299,21 @@ struct ClipCardView: View { Label(AppController.shared.pasteMenuTitle, systemImage: "doc.on.clipboard") } - Button { AppController.shared.pasteItem(item, asPlainText: true) } label: { + Button { AppController.shared.pasteItem(item, format: .plainText) } label: { Label("Paste as Plain Text", systemImage: "text.alignleft") } .disabled(item.plainText == nil) + Button { AppController.shared.pasteItem(item, format: .cleanFormatting) } label: { + Label("Paste with Clean Formatting", systemImage: "paintbrush") + } + .disabled(!FormatConverter.canConvert(item)) + + Button { AppController.shared.pasteItem(item, format: .markdown) } label: { + Label("Paste as Markdown", systemImage: "number") + } + .disabled(!FormatConverter.canConvert(item)) + Button { AppController.shared.copyItem(item) } label: { Label("Copy", systemImage: "doc.on.doc") } @@ -308,3 +420,59 @@ struct ClipCardView: View { return image } } + +/// The standard transparency checkerboard drawn behind image clips. A Canvas +/// rather than a tiled image: the pattern is a handful of rects, and this keeps +/// it resolution independent without shipping an asset. +private struct CheckerboardBackground: View { + var square: CGFloat = 8 + + var body: some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white)) + let columns = Int(ceil(size.width / square)) + let rows = Int(ceil(size.height / square)) + for row in 0.. CGSize? { + guard item.imageFileName != nil, + let url = ClipboardStore.shared.imageURL(for: item) else { return nil } + return of(url) + } + + static func of(_ url: URL) -> CGSize? { + let name = url.path + if let cached = cache[name] { return cached } + if cache.count >= cacheLimit { cache.removeAll() } + let size = read(url) + cache.updateValue(size, forKey: name) + return size + } + + private static func read(_ url: URL) -> CGSize? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { return nil } + return CGSize(width: width, height: height) + } +} diff --git a/Sources/Pesty/UI/CopyToastController.swift b/Sources/Pesty/UI/CopyToastController.swift new file mode 100644 index 0000000..1e5601f --- /dev/null +++ b/Sources/Pesty/UI/CopyToastController.swift @@ -0,0 +1,87 @@ +import AppKit +import SwiftUI + +@MainActor +final class CopyToastController { + private let panel: NSPanel + private let host: NSHostingView + private var dismissWorkItem: DispatchWorkItem? + + init() { + host = NSHostingView(rootView: CopyToastView(message: "", symbol: "")) + host.sizingOptions = .intrinsicContentSize + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 236, height: 48), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .floating + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.contentView = host + } + + /// A notice sized to its text; the default is the plain copy confirmation. + func show(message: String = "Copied to Clipboard", + symbol: String = "checkmark.circle.fill", + linger: TimeInterval = 1.25) { + dismissWorkItem?.cancel() + let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return } + + host.rootView = CopyToastView(message: message, symbol: symbol) + let size = host.intrinsicContentSize + if size.width > 0, size.height > 0 { panel.setContentSize(size) } + let frame = screen.visibleFrame + panel.setFrameOrigin(NSPoint(x: frame.midX - panel.frame.width / 2, y: frame.minY + 34)) + panel.alphaValue = 0 + panel.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + panel.animator().alphaValue = 1 + } + + let dismiss = DispatchWorkItem { [weak self] in self?.dismiss() } + dismissWorkItem = dismiss + DispatchQueue.main.asyncAfter(deadline: .now() + linger, execute: dismiss) + } + + private func dismiss() { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.18 + panel.animator().alphaValue = 0 + }, completionHandler: { [weak panel] in + panel?.orderOut(nil) + }) + } +} + +private struct CopyToastView: View { + let message: String + let symbol: String + + var body: some View { + HStack(spacing: 9) { + Image(systemName: symbol) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Theme.selection) + Text(message) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(.regularMaterial, in: Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .strokeBorder(.white.opacity(0.28)) + } + } +} diff --git a/Sources/Pesty/UI/Theme.swift b/Sources/Pesty/UI/Theme.swift index fc6d6db..7821147 100644 --- a/Sources/Pesty/UI/Theme.swift +++ b/Sources/Pesty/UI/Theme.swift @@ -6,6 +6,7 @@ enum Theme { static let cornerRadius: CGFloat = 16 static let cardCorner: CGFloat = 19 static let headerHeight: CGFloat = 68 + static let fileIconSize: CGFloat = 64 static let panelTint = Color.white.opacity(0.10) static let cardBody = Color.white.opacity(0.94) diff --git a/Sources/Pesty/Util/ClipPreviewProvider.swift b/Sources/Pesty/Util/ClipPreviewProvider.swift new file mode 100644 index 0000000..133e64c --- /dev/null +++ b/Sources/Pesty/Util/ClipPreviewProvider.swift @@ -0,0 +1,159 @@ +import AppKit +import ImageIO +import QuickLookThumbnailing +import UniformTypeIdentifiers + +/// Cards are a few hundred points wide, so a full-size screenshot would only +/// cost memory; previews are decoded no larger than this on their longest side. +private let maxPreviewPixels = 1024 + +/// What a card would otherwise read from disk on every render: the picture for +/// image clips and image files, and the Finder icon for other file clips. +/// Lookups are synchronous and never touch disk; loading runs off the main +/// actor and fills caches bounded by count and byte cost. +@MainActor +enum ClipPreviewProvider { + struct FileInfo { + let icon: NSImage + let isMissing: Bool + } + + private static let images: NSCache = { + let cache = NSCache() + cache.countLimit = 200 + cache.totalCostLimit = 64 * 1024 * 1024 + return cache + }() + + private static let icons: NSCache = { + let cache = NSCache() + cache.countLimit = 256 + return cache + }() + + /// The one file a `.file` clip points at when that file is an image. Decided + /// from the path extension so it is safe to ask on every render. + static func imageFileURL(for item: ClipItem) -> URL? { + guard item.type == .file, + item.fileURLs.count == 1, + let url = item.fileURLs.first.flatMap(URL.init(string:)), + url.isFileURL, + let type = UTType(filenameExtension: url.pathExtension), + type.conforms(to: .image) else { return nil } + return url + } + + static func showsImage(_ item: ClipItem) -> Bool { + item.type == .image || imageFileURL(for: item) != nil + } + + static func cachedImage(for item: ClipItem) -> NSImage? { + guard let key = imageKey(for: item) else { return nil } + return images.object(forKey: key) + } + + static func image(for item: ClipItem, scale: CGFloat) async -> NSImage? { + guard let key = imageKey(for: item) else { return nil } + if let cached = images.object(forKey: key) { return cached } + let loaded: NSImage? + if item.type == .image, let url = ClipboardStore.shared.imageURL(for: item) { + loaded = await downsampled(url) + } else if let url = imageFileURL(for: item) { + loaded = await thumbnail(for: url, scale: scale) + } else { + loaded = nil + } + guard let loaded else { return nil } + images.setObject(loaded, forKey: key, cost: cost(of: loaded)) + return loaded + } + + static func cachedIcon(for item: ClipItem) -> NSImage? { + guard let url = firstFileURL(for: item) else { return nil } + return icons.object(forKey: url.path as NSString) + } + + /// The file's own Finder icon when it is there, and its type's icon when it + /// is not: the workspace hands back a blank page for a path that no longer + /// exists. A file counts as missing only on ENOENT, and never in the + /// sandboxed build, where access to a copied file does not survive a + /// relaunch and the stat fails for files that are still there. + static func fileInfo(for item: ClipItem) async -> FileInfo? { + guard let url = firstFileURL(for: item) else { return nil } + let path = url.path + let status = await Task.detached(priority: .userInitiated) { Self.fileStatus(path) }.value + guard status == .present else { + let icon = NSWorkspace.shared.icon(for: UTType(filenameExtension: url.pathExtension) ?? .data) + return FileInfo(icon: icon, isMissing: status == .missing && !ClipboardStore.isSandboxed) + } + let key = path as NSString + if let cached = icons.object(forKey: key) { return FileInfo(icon: cached, isMissing: false) } + let icon = NSWorkspace.shared.icon(forFile: path) + icons.setObject(icon, forKey: key) + return FileInfo(icon: icon, isMissing: false) + } + + private static func imageKey(for item: ClipItem) -> NSString? { + if item.type == .image { + guard let path = ClipboardStore.shared.imageURL(for: item)?.path else { return nil } + return "\(path)|\(item.imageHash ?? "")" as NSString + } + return imageFileURL(for: item)?.path as NSString? + } + + private static func firstFileURL(for item: ClipItem) -> URL? { + guard item.type == .file, + let url = item.fileURLs.first.flatMap(URL.init(string:)), + url.isFileURL else { return nil } + return url + } + + private static func downsampled(_ url: URL) async -> NSImage? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: Self.decode(url)) + } + } + } + + private static func thumbnail(for url: URL, scale: CGFloat) async -> NSImage? { + let side = CGFloat(maxPreviewPixels) / scale + let request = QLThumbnailGenerator.Request(fileAt: url, + size: CGSize(width: side, height: side), + scale: scale, + representationTypes: .thumbnail) + let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request) + return representation?.nsImage + } + + private static func cost(of image: NSImage) -> Int { + let rep = image.representations.first + let width = rep?.pixelsWide ?? Int(image.size.width) + let height = rep?.pixelsHigh ?? Int(image.size.height) + return width * height * 4 + } + + private enum FileStatus { + case present + case missing + case unknown + } + + nonisolated private static func fileStatus(_ path: String) -> FileStatus { + var info = stat() + if stat(path, &info) == 0 { return .present } + return errno == ENOENT ? .missing : .unknown + } + + nonisolated private static func decode(_ url: URL) -> NSImage? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPreviewPixels + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } + return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } +} diff --git a/Sources/Pesty/Util/FormatConverter.swift b/Sources/Pesty/Util/FormatConverter.swift new file mode 100644 index 0000000..72a11a0 --- /dev/null +++ b/Sources/Pesty/Util/FormatConverter.swift @@ -0,0 +1,115 @@ +import AppKit + +/// Converts rich clips for the "Clean Formatting" and "Markdown" paste +/// options. HTML is preferred as the source when the clip carried it — +/// browser copies often have no RTF at all — with RTF as the fallback. +enum FormatConverter { + /// Above this, skip rich conversion entirely and let the caller fall back + /// to plain text. The HTML importer runs synchronously on the main thread + /// at paste time; a pathological clip must not hitch the paste. + private static let maxRichSourceBytes = 1_000_000 + + static func canConvert(_ item: ClipItem) -> Bool { + withinLimit(item.htmlData) || withinLimit(item.rtfData) + } + + private static func withinLimit(_ data: Data?) -> Bool { + guard let data else { return false } + return !data.isEmpty && data.count <= maxRichSourceBytes + } + + private static func attributed(for item: ClipItem) -> NSAttributedString? { + // The importer loads no subresources, so the HTML goes in as captured; + // the timeout only bounds the document load itself. + if let html = item.htmlData, withinLimit(html), + let s = NSAttributedString(html: html, + options: [.timeout: 2.0], + documentAttributes: nil) { + return s + } + if let rtf = item.rtfData, withinLimit(rtf), + let s = NSAttributedString(rtf: rtf, documentAttributes: nil) { + return s + } + return nil + } + + /// Strips fonts, sizes, and colors while keeping the structure people + /// mean when they say "formatting": bold, italic, underline, + /// strikethrough, and links — everything else becomes the system font. + static func cleanedRTF(for item: ClipItem) -> Data? { + guard let source = attributed(for: item) else { return nil } + let base = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let out = NSMutableAttributedString(string: source.string) + let full = NSRange(location: 0, length: out.length) + out.addAttribute(.font, value: base, range: full) + + source.enumerateAttributes(in: NSRange(location: 0, length: source.length)) { attrs, range, _ in + if let font = attrs[.font] as? NSFont { + var kept: NSFontDescriptor.SymbolicTraits = [] + let traits = font.fontDescriptor.symbolicTraits + if traits.contains(.bold) { kept.insert(.bold) } + if traits.contains(.italic) { kept.insert(.italic) } + if !kept.isEmpty, + let styled = NSFont(descriptor: base.fontDescriptor.withSymbolicTraits(kept), + size: base.pointSize) { + out.addAttribute(.font, value: styled, range: range) + } + } + if let underline = attrs[.underlineStyle] { + out.addAttribute(.underlineStyle, value: underline, range: range) + } + if let strike = attrs[.strikethroughStyle] { + out.addAttribute(.strikethroughStyle, value: strike, range: range) + } + if let link = attrs[.link] { + out.addAttribute(.link, value: link, range: range) + } + } + return out.rtf(from: full) + } + + /// Walks the attributed runs producing Markdown for bold, italic, and + /// links. Markers never wrap line breaks — each line segment inside a + /// styled run is marked separately so the output stays valid Markdown. + static func markdown(for item: ClipItem) -> String? { + guard let source = attributed(for: item) else { return nil } + var out = "" + let text = source.string as NSString + + source.enumerateAttributes(in: NSRange(location: 0, length: source.length)) { attrs, range, _ in + let chunk = text.substring(with: range) + guard !chunk.isEmpty else { return } + + let traits = (attrs[.font] as? NSFont)?.fontDescriptor.symbolicTraits ?? [] + var marker = "" + if traits.contains(.bold) { marker += "**" } + if traits.contains(.italic) { marker += "*" } + let linkTarget: String? = (attrs[.link] as? URL)?.absoluteString + ?? attrs[.link] as? String + + // Split on newlines, keeping empty segments so blank lines survive. + let segments = chunk.components(separatedBy: "\n") + for (index, segment) in segments.enumerated() { + if index > 0 { out += "\n" } + guard !segment.isEmpty else { continue } + // Markers hug the visible text; whitespace stays outside them. + let leading = segment.prefix(while: { $0 == " " || $0 == "\t" }) + let trailing = String(segment.reversed().prefix(while: { $0 == " " || $0 == "\t" }).reversed()) + let core = segment.dropFirst(leading.count).dropLast(trailing.count) + guard !core.isEmpty else { out += segment; continue } + + var rendered = String(core) + if let linkTarget, rendered.trimmingCharacters(in: .whitespaces) != linkTarget { + rendered = "[\(rendered)](\(linkTarget))" + } + if !marker.isEmpty { + rendered = "\(marker)\(rendered)\(String(marker.reversed()))" + } + out += String(leading) + rendered + trailing + } + } + let trimmed = out.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..e6073a2 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @MainActor @@ -22,12 +23,119 @@ enum SourceColor { UserDefaults.standard.dictionary(forKey: key) as? [String: Int] ?? [:] }() + // Ten deterministic variants of a single user-picked accent color, used + // by the "Accent shades" theme so every source app keeps a stable, + // recognizable shade without drifting far from the chosen color. + 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) + ] + 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] } + switch Settings.shared.clipColorTheme { + case .default: + return palette[paletteIndex(for: bundleID)] + case .vibrant: + return vibrantPalette[paletteIndex(for: bundleID)] + case .accentShades: + return accentShade( + for: bundleID?.isEmpty == false ? bundleID! : "unknown", + accentHex: Settings.shared.clipColorAccentHex + ) + } + } + + /// The ten deterministic shades for a given accent color, used by + /// Settings to preview the "Accent shades" theme. + static func accentShades(for accentHex: String) -> [Color] { + accentVariants.map { accentShade(variant: $0, accentHex: accentHex) } + } + + /// A stable palette slot assigned on first sight and persisted, so a given + /// app keeps its color across launches. + private static func paletteIndex(for bundleID: String?) -> Int { + guard let id = bundleID, !id.isEmpty else { return 0 } + if let i = map[id] { return i % palette.count } let i = map.count % palette.count map[id] = i UserDefaults.standard.set(map, forKey: key) - return palette[i] + return i + } + + private static let vibrantPalette: [Color] = palette.map { vibrantColor(from: $0) } + + private static func vibrantColor(from color: Color) -> Color { + guard let nsColor = NSColor(color).usingColorSpace(.sRGB) else { return color } + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + + // Make the vibrant option visibly distinct while keeping headers dark + // enough for their white labels to remain readable. + return Color( + hue: Double(hue), + saturation: min(0.99, max(0.92, Double(saturation) * 1.08)), + brightness: min(0.90, max(0.68, Double(brightness) * 0.90)) + ) + } + + 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 var accentBase: (hex: String, hue: Double, saturation: Double, brightness: Double)? + + private static func accentBaseHSB(for accentHex: String) -> (hue: Double, saturation: Double, brightness: Double) { + if let base = accentBase, base.hex == accentHex { + return (base.hue, base.saturation, base.brightness) + } + 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) + accentBase = (accentHex, Double(hue), Double(saturation), Double(brightness)) + return (Double(hue), Double(saturation), Double(brightness)) + } + + private static func accentShade(variant: AccentVariant, accentHex: String) -> Color { + let base = accentBaseHSB(for: accentHex) + + // 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, base.brightness)) + let adjustedHue = (base.hue + variant.hueOffset + 1).truncatingRemainder(dividingBy: 1) + + return Color( + hue: adjustedHue, + saturation: min(0.98, max(0.60, base.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 } } diff --git a/Tests/PestyTests/ClipItemTests.swift b/Tests/PestyTests/ClipItemTests.swift new file mode 100644 index 0000000..ee6cec5 --- /dev/null +++ b/Tests/PestyTests/ClipItemTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import Pesty + +final class ClipItemTests: XCTestCase { + func testCodableRoundTripPreservesClipMetadata() throws { + let original = ClipItem( + id: UUID(uuidString: "61F39299-3C8E-45AE-90BA-C53889BF9304")!, + type: .link, + text: "https://example.com/path", + sourceBundleID: "com.apple.Safari", + sourceAppName: "Safari", + customTitle: "Example", + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ClipItem.self, from: encoded) + + XCTAssertEqual(decoded, original) + XCTAssertEqual(decoded.displayTitle, "Example") + } +} diff --git a/Tests/PestyTests/ClipPreviewProviderTests.swift b/Tests/PestyTests/ClipPreviewProviderTests.swift new file mode 100644 index 0000000..860ef63 --- /dev/null +++ b/Tests/PestyTests/ClipPreviewProviderTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Pesty + +@MainActor +final class ClipPreviewProviderTests: XCTestCase { + func testOnlySingleImageFilesShowAsImages() { + let png = ClipItem(type: .file, fileURLs: ["file:///tmp/shot.png"]) + let pdf = ClipItem(type: .file, fileURLs: ["file:///tmp/report.pdf"]) + let two = ClipItem(type: .file, fileURLs: ["file:///tmp/a.png", "file:///tmp/b.png"]) + + XCTAssertEqual(ClipPreviewProvider.imageFileURL(for: png)?.path, "/tmp/shot.png") + XCTAssertNil(ClipPreviewProvider.imageFileURL(for: pdf)) + XCTAssertNil(ClipPreviewProvider.imageFileURL(for: two)) + XCTAssertTrue(ClipPreviewProvider.showsImage(ClipItem(type: .image, imageFileName: "x.png"))) + XCTAssertFalse(ClipPreviewProvider.showsImage(pdf)) + } + + func testFileInfoReportsMissingFilesOnlyOnENOENT() async { + let present = ClipItem(type: .file, fileURLs: ["file:///bin/ls"]) + let gone = ClipItem(type: .file, fileURLs: ["file:///nonexistent/\(UUID().uuidString).pdf"]) + + let presentInfo = await ClipPreviewProvider.fileInfo(for: present) + XCTAssertEqual(presentInfo?.isMissing, false) + XCTAssertNotNil(presentInfo?.icon) + + let goneInfo = await ClipPreviewProvider.fileInfo(for: gone) + XCTAssertEqual(goneInfo?.isMissing, !ClipboardStore.isSandboxed) + XCTAssertNotNil(goneInfo?.icon) + + let text = await ClipPreviewProvider.fileInfo(for: ClipItem(type: .text, text: "x")) + XCTAssertNil(text) + } +} diff --git a/Tests/PestyTests/ClipUndoStackTests.swift b/Tests/PestyTests/ClipUndoStackTests.swift new file mode 100644 index 0000000..4156e40 --- /dev/null +++ b/Tests/PestyTests/ClipUndoStackTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import Pesty + +final class ClipUndoStackTests: XCTestCase { + private let start = Date(timeIntervalSince1970: 1_700_000_000) + + private func placement(_ item: ClipItem, index: Int, + before predecessor: ClipItem? = nil, + after successor: ClipItem? = nil) -> ClipPlacement { + ClipPlacement(container: .history, index: index, item: item, + predecessorID: predecessor?.id, successorID: successor?.id) + } + + func testBulkDeletionComesBackAsOneEntryNewestFirst() { + var stack = ClipUndoStack() + let a = ClipItem(type: .text, text: "a") + let b = ClipItem(type: .text, text: "b") + let c = ClipItem(type: .text, text: "c") + stack.push([placement(a, index: 0)], at: start) + stack.push([placement(b, index: 1), placement(c, index: 2)], at: start.addingTimeInterval(1)) + + let first = stack.pop(at: start.addingTimeInterval(2)) + XCTAssertEqual(first?.placements.map(\.item.id), [b.id, c.id]) + XCTAssertEqual(stack.pop(at: start.addingTimeInterval(2))?.placements.first?.item.id, a.id) + XCTAssertNil(stack.pop(at: start.addingTimeInterval(2))) + XCTAssertTrue(stack.isEmpty) + } + + func testEntriesExpireAfterFiveMinutes() { + var stack = ClipUndoStack() + let item = ClipItem(type: .image, imageFileName: "shot.png") + stack.push([placement(item, index: 0)], at: start) + XCTAssertEqual(stack.nextExpiration, start.addingTimeInterval(300)) + XCTAssertTrue(stack.retainsImageFile(named: "shot.png")) + + XCTAssertTrue(stack.removeExpired(at: start.addingTimeInterval(299)).isEmpty) + XCTAssertNil(stack.pop(at: start.addingTimeInterval(300))) + let expired = stack.removeExpired(at: start.addingTimeInterval(300)) + XCTAssertEqual(expired.first?.placements.first?.item.id, item.id) + XCTAssertFalse(stack.retainsImageFile(named: "shot.png")) + XCTAssertNil(stack.nextExpiration) + } + + func testRemoveAllHandsBackEverything() { + var stack = ClipUndoStack() + stack.push([placement(ClipItem(type: .text, text: "x"), index: 0)], at: start) + stack.push([placement(ClipItem(type: .text, text: "y"), index: 0)], at: start) + XCTAssertEqual(stack.removeAll().count, 2) + XCTAssertTrue(stack.isEmpty) + } + + func testRestorationIndexPrefersPredecessorThenSuccessorThenIndex() { + let a = ClipItem(type: .text, text: "a") + let b = ClipItem(type: .text, text: "b") + let c = ClipItem(type: .text, text: "c") + let gone = placement(b, index: 1, before: a, after: c) + + XCTAssertEqual(gone.restorationIndex(in: [a, c]), 1) + XCTAssertEqual(gone.restorationIndex(in: [c, a]), 2) + XCTAssertEqual(gone.restorationIndex(in: [c]), 0) + XCTAssertEqual(gone.restorationIndex(in: []), 0) + XCTAssertEqual(placement(b, index: 7).restorationIndex(in: [a, c]), 2) + } +}