From d9121f82d81e582997159e117675bcb89a67baac Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Wed, 12 Aug 2026 21:44:43 -0700 Subject: [PATCH 01/23] Add a standalone SwiftPM smoke test --- Package.swift | 5 +++++ Tests/PestyTests/ClipItemTests.swift | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 Tests/PestyTests/ClipItemTests.swift 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/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") + } +} From 741918111e5a106f3dca7311c34b43e93899551e Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 8 Oct 2026 04:53:11 +0300 Subject: [PATCH 02/23] Add a "Play sound on copy" setting, distinct from paste Plays the system "Tink" sound when a clip is copied from the bar via copyItem(_:), kept audibly distinct from the existing "Play sound on paste" toggle, which plays "Pop" from PasteService.paste(_:). Both sound toggles ship disabled: an update should not introduce a new audible behavior existing users never asked for, and two sibling settings with opposite defaults would read as an oversight. --- Sources/Pesty/AppController.swift | 2 ++ Sources/Pesty/Settings/Settings.swift | 9 +++++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + 3 files changed, 12 insertions(+) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d751356..3dbc16e 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -326,6 +326,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { func copyItem(_ item: ClipItem) { let change = PasteService.copy(item) monitor.suppressUntilChangeCount = change + // Tink, not Pop: copy and paste stay audibly distinct. + if Settings.shared.playSoundOnCopy { NSSound(named: "Tink")?.play() } hideBar() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..d79dc42 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -79,6 +79,7 @@ final class Settings { static let hideOnClickOutside = "hideOnClickOutside" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" + static let playSoundOnCopy = "playSoundOnCopy" static let ignoreConcealed = "ignoreConcealed" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" @@ -154,6 +155,10 @@ 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) } + } + var ignoreConcealed: Bool { didSet { guard isLoaded else { return }; d.set(ignoreConcealed, forKey: Keys.ignoreConcealed) } } @@ -207,6 +212,9 @@ 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.ignoreConcealed: true, Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, @@ -227,6 +235,7 @@ 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) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) .filter { !$0.isEmpty } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..8ed6eb8 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -133,6 +133,7 @@ private struct GeneralSettings: View { #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) + Toggle("Play sound on copy", 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) From bc5fad5a1ebf5038277f631e05ac8f99f237543c Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 04:54:28 +0300 Subject: [PATCH 03/23] Say which copy the sound setting means "Play sound on copy" read as a sound for every copy Pesty captures. The only thing that plays it is the card menu's Copy, so the toggle now reads "Play sound when copying from Pesty". Based on #90 by @alvst. --- Sources/Pesty/Settings/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 8ed6eb8..0b48f81 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -133,7 +133,7 @@ private struct GeneralSettings: View { #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) - Toggle("Play sound on copy", isOn: $settings.playSoundOnCopy) + 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) From 88342b8c745d79d1bca6bfa8825ca2b0a81739dc Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 21:07:05 -0700 Subject: [PATCH 04/23] Show history's on-disk size in Settings The Keep History section gains a "Currently storing" row: clip count plus the store directory's actual size (history JSON and saved images), walked off the main actor when the pane opens so a large image library can't hitch the Settings window. --- Sources/Pesty/Settings/SettingsView.swift | 32 +++++++++++++++++++++++ Sources/Pesty/Store/ClipboardStore.swift | 4 +++ 2 files changed, 36 insertions(+) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 0b48f81..7271d34 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -243,12 +243,42 @@ 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? 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 storageSummary: String { + let count = ClipboardStore.shared.history.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") { Picker("Limit history by", selection: $draftMode) { @@ -271,7 +301,9 @@ private struct HistoryRetentionSettings: View { Text(footnote) .font(.caption) .foregroundStyle(.secondary) + LabeledContent("Currently storing", value: storageSummary) } + .task { await refreshStorageSize() } .onChange(of: draftMode) { evaluateDraft() } .onChange(of: draftLimit) { evaluateDraft() } .onChange(of: draftDays) { evaluateDraft() } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index c0bcd40..9af4a37 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -66,6 +66,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 From abdc7427cbcc4e983f72ba821be49ac87d64c59a Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 04:55:10 +0300 Subject: [PATCH 05/23] Keep the storage row current and count what it measures The size was measured once when the pane appeared, so clearing history left the old number next to "0 clips" until the window was rebuilt. It is now re-measured after every store save. The bytes cover history and pinboards, since pinned clips keep their own image files, so the row is labeled that way and the clip count includes pinned clips. Based on #91 by @alvst. --- Sources/Pesty/Settings/SettingsView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 7271d34..bcda6e4 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -251,7 +251,8 @@ private struct HistoryRetentionSettings: View { ] private var storageSummary: String { - let count = ClipboardStore.shared.history.count + 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))" @@ -301,9 +302,14 @@ private struct HistoryRetentionSettings: View { Text(footnote) .font(.caption) .foregroundStyle(.secondary) - LabeledContent("Currently storing", value: storageSummary) + LabeledContent("History and pinboards on disk", value: storageSummary) + } + .task { + await refreshStorageSize() + for await _ in NotificationCenter.default.notifications(named: .pestyStoreDidSave) { + await refreshStorageSize() + } } - .task { await refreshStorageSize() } .onChange(of: draftMode) { evaluateDraft() } .onChange(of: draftLimit) { evaluateDraft() } .onChange(of: draftDays) { evaluateDraft() } From f8e948f507569247f953a555715be5e1e1e6eae7 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Tue, 18 Aug 2026 20:41:38 -0700 Subject: [PATCH 06/23] Show real dimensions and paths in the clip card footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer's "Image" and "1 file" told you nothing you couldn't already see from the card itself. Image clips now read their pixel dimensions - "1920 × 1080" - and file clips say which files they actually point at. What a file clip's footer says depends on what the file is. An image already shows what it is by sitting on the card, so its dimensions are the useful fact - a screenshot's path is a timestamped folder nobody reads. Everything else gets its full location instead, abbreviated with "~", because two documents of the same name differ only by where they live. Multi-file clips list every filename rather than a bare "N files" count, which named none of them. Whether a file is an image is decided from its path extension, never by opening it: the footer is evaluated on every render, so it must not touch disk. A path is worth reading in full, so the meta line wraps to three lines for file clips rather than collapsing to an ellipsis; every other type stays on one line. The footer row aligns on its bottom edge so the quick-paste hint stays put as the path grows. Dimensions come from the image file's metadata via CGImageSource rather than from decoding it, and are cached by path: the footer asks on every render, so a decode per card would be paid over and over for a number that never changes. Clips whose image file is missing or unreadable fall back to the old "Image" label, and an image file whose size can't be read falls back to its filename. --- Sources/Pesty/UI/ClipCardView.swift | 65 +++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index a8b4725..8738c90 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,5 +1,7 @@ import AppKit +import ImageIO import SwiftUI +import UniformTypeIdentifiers struct ClipCardView: View { let item: ClipItem @@ -155,11 +157,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 +181,17 @@ struct ClipCardView: View { .padding(.top, 8) } + /// Matched on the path extension rather than by loading the file: this is + /// evaluated on every card render, so it must not touch disk. + private var isSingleImageFile: Bool { + 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) else { return false } + return type.conforms(to: .image) + } + private var metaLeft: String { switch item.type { case .text, .richText: @@ -183,9 +200,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 (url.path as NSString).abbreviatingWithTildeInPath 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" } @@ -308,3 +340,28 @@ struct ClipCardView: View { return image } } + +/// Reads an image clip's pixel dimensions from the file's metadata instead of +/// decoding it, and remembers them: the card footer asks on every render. +@MainActor +enum ImagePixelSize { + private static var cache: [String: CGSize] = [:] + + static func of(_ item: ClipItem) -> 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 } + 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 } + let size = CGSize(width: width, height: height) + cache[name] = size + return size + } +} From 3e1ff4a0787fd9ed88d37f6054c04b3602be6a99 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 04:56:13 +0300 Subject: [PATCH 07/23] Remember missing images and abbreviate paths with the real home ImagePixelSize cached only successful reads, so a card whose image file is gone asked ImageIO for it again on every render. Misses are cached too, and the cache is emptied once it passes 512 entries. abbreviatingWithTildeInPath goes through NSHomeDirectory, which in the sandboxed App Store build is the container, so paths never shortened there. The home now comes from getpwuid, which a sandboxed probe showed is the only one of NSHomeDirectory, homeDirectoryForCurrentUser and NSHomeDirectoryForUser(NSUserName()) that returns /Users/. Based on #95 by @alvst. --- Sources/Pesty/UI/ClipCardView.swift | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 8738c90..09b71cc 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -192,6 +192,19 @@ struct ClipCardView: View { return type.conforms(to: .image) } + /// 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: @@ -214,7 +227,7 @@ struct ClipCardView: View { guard let size = ImagePixelSize.of(url) else { return url.lastPathComponent } return "\(Int(size.width)) × \(Int(size.height))" } - return (url.path as NSString).abbreviatingWithTildeInPath + return Self.abbreviatedPath(url.path) case .image: guard let size = ImagePixelSize.of(item) else { return "Image" } return "\(Int(size.width)) × \(Int(size.height))" @@ -345,7 +358,8 @@ struct ClipCardView: View { /// decoding it, and remembers them: the card footer asks on every render. @MainActor enum ImagePixelSize { - private static var cache: [String: CGSize] = [:] + private static var cache: [String: CGSize?] = [:] + private static let cacheLimit = 512 static func of(_ item: ClipItem) -> CGSize? { guard item.imageFileName != nil, @@ -356,12 +370,17 @@ enum ImagePixelSize { 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 } - let size = CGSize(width: width, height: height) - cache[name] = size - return size + return CGSize(width: width, height: height) } } From 3488572dc84a1acf5e994784eec9800cbe2d16f9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 19:15:10 -0700 Subject: [PATCH 08/23] Turn history retention into a discrete slider with named presets Replaces the "remove clips older than" day-choice Picker with a 10-stop slider (1 Day through Forever) matching Alvie's Pesty's granularity, via a new HistoryRetentionPreset enum. Adds three new stops baseline didn't have (3 Weeks, 2 Months, Forever) - Forever is represented as historyRetentionDays == 0, meaning "no automatic pruning by age", with only the existing safety cap still applying. Keeps the confirm-before-apply destructive-change dialog baseline already had; only the presentation of the choice changes, not the safety UX around it. The slider itself needed a fix unrelated to its content: on macOS, Form(.formStyle(.grouped)) lays out every row in a Section's body on a shared label/control NSGridView, which clamps and right-shifts any bare control placed there regardless of SwiftUI-side frame modifiers. Moving the slider block into the Section's footer (plain full-width content, never part of that grid) fixes it - see the comment at the call site for how this was confirmed. --- Sources/Pesty/Settings/Settings.swift | 86 ++++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 65 ++++++++++++++--- Sources/Pesty/Store/ClipboardStore.swift | 18 +++-- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index d79dc42..939851d 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -59,6 +59,85 @@ 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] + } +} + @Observable @MainActor final class Settings { @@ -104,10 +183,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) } } @@ -226,7 +308,7 @@ final class Settings { 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) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index bcda6e4..37d677c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,10 +245,14 @@ private struct HistoryRetentionSettings: View { @State private var confirmingChange = false @State private var storageBytes: Int64? - 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 draftPreset: HistoryRetentionPreset { HistoryRetentionPreset(nearestDays: draftDays) } + + private var draftPresetSliderValue: Binding { + Binding( + get: { draftPreset.sliderIndex }, + set: { draftDays = HistoryRetentionPreset(sliderIndex: $0).days } + ) + } private var storageSummary: String { let store = ClipboardStore.shared @@ -281,7 +285,7 @@ private struct HistoryRetentionSettings: View { } var body: some View { - Section("History") { + Section { Picker("Limit history by", selection: $draftMode) { ForEach(HistoryRetentionMode.allCases) { mode in Text(mode.title).tag(mode) @@ -292,16 +296,59 @@ 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: { + // On macOS, Form(.formStyle(.grouped)) lays out every row placed + // in a Section's *body* on a shared two-column label/control grid + // (backed by NSGridView), sized from the widest label anywhere in + // that Section - here "Limit history by". Any native AppKit + // control dropped into that body (Slider, Picker, Stepper, ...) + // gets clamped and shifted into the trailing "control column" of + // that grid, even with no visible label of its own and even with + // an explicit SwiftUI .frame() on it, because the constraint is + // applied by the grid to the control's AppKit host view *after* + // SwiftUI layout runs - which is why .frame(maxWidth: .infinity), + // an HStack wrapper, and a GeometryReader forcing an exact width + // all had zero effect (confirmed by dumping the live NSView tree: + // the same Slider measured ~253pt wide starting at x≈217 in a + // 480pt-wide row when placed in the body, vs. ~460pt wide + // starting at x≈30 when placed here). A Section's footer is + // rendered as plain full-width content below that grid, not as a + // grid row, so it never gets pulled into the label/control + // layout - which is why moving this block here (rather than + // tweaking the Slider itself yet again) actually fixes the width + // and alignment, and also guarantees the three rows below line + // up with each other since they're now plain VStack siblings + // sharing one container instead of being split across the grid. + if draftMode != .itemCount { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Remove clips older than") + Spacer() + Text(draftPreset.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: draftPresetSliderValue, + in: 0...Double(HistoryRetentionPreset.allCases.count - 1), + step: 1) + HStack(spacing: 0) { + ForEach(HistoryRetentionPreset.allCases) { preset in + Text(preset.shortTitle) + .font(.system(size: 10, weight: preset == draftPreset ? .bold : .medium)) + .foregroundStyle(preset == draftPreset ? 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 { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 9af4a37..6169f20 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -134,6 +134,10 @@ final class ClipboardStore { case .itemCount: return max(0, history.count - max(20, limit)) case .timeInterval: + // days == 0 means "forever" - no age-based cutoff, just the safety cap. + guard days > 0 else { + return max(0, history.count - Self.timeRetentionSafetyCap) + } let cutoff = Self.retentionCutoff(daysAgo: days) let byAge = history.filter { $0.createdAt < cutoff }.count return byAge + max(0, (history.count - byAge) - Self.timeRetentionSafetyCap) @@ -161,11 +165,15 @@ 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. + let days = Settings.shared.historyRetentionDays + if days > 0 { + let cutoff = Self.retentionCutoff(daysAgo: days) + 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...]) From d84964d5b63a4f37f1d9db5205f05d2bf3c92bfd Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 04:58:17 +0300 Subject: [PATCH 09/23] Apply the retention slider when the drag ends The slider wrote the draft at every stop it crossed, and each write ran the removal check: dragging from 1 Year toward 1 Day committed 6 Months on the way past it, then put up the confirm alert for 3 Months while the mouse was still down, and Cancel restored 6 Months instead of 1 Year. The slider now moves a local value and applies it once editing ends (or on a keyboard step), so nothing is committed during a drag and Cancel returns to the value the drag started from. The stop under the thumb is still shown live. retentionCutoff returns nil for 0 days instead of silently treating it as 1, so Forever cannot regress through a new caller. Based on #89 by @alvst. --- Sources/Pesty/Settings/SettingsView.swift | 55 +++++++++-------------- Sources/Pesty/Store/ClipboardStore.swift | 12 +++-- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 37d677c..d84a38c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,14 +245,10 @@ private struct HistoryRetentionSettings: View { @State private var confirmingChange = false @State private var storageBytes: Int64? - private var draftPreset: HistoryRetentionPreset { HistoryRetentionPreset(nearestDays: draftDays) } + @State private var sliderValue = HistoryRetentionPreset(nearestDays: Settings.shared.historyRetentionDays).sliderIndex + @State private var sliderEditing = false - private var draftPresetSliderValue: Binding { - Binding( - get: { draftPreset.sliderIndex }, - set: { draftDays = HistoryRetentionPreset(sliderIndex: $0).days } - ) - } + private var sliderPreset: HistoryRetentionPreset { HistoryRetentionPreset(sliderIndex: sliderValue) } private var storageSummary: String { let store = ClipboardStore.shared @@ -300,45 +296,29 @@ private struct HistoryRetentionSettings: View { } header: { Text("History") } footer: { - // On macOS, Form(.formStyle(.grouped)) lays out every row placed - // in a Section's *body* on a shared two-column label/control grid - // (backed by NSGridView), sized from the widest label anywhere in - // that Section - here "Limit history by". Any native AppKit - // control dropped into that body (Slider, Picker, Stepper, ...) - // gets clamped and shifted into the trailing "control column" of - // that grid, even with no visible label of its own and even with - // an explicit SwiftUI .frame() on it, because the constraint is - // applied by the grid to the control's AppKit host view *after* - // SwiftUI layout runs - which is why .frame(maxWidth: .infinity), - // an HStack wrapper, and a GeometryReader forcing an exact width - // all had zero effect (confirmed by dumping the live NSView tree: - // the same Slider measured ~253pt wide starting at x≈217 in a - // 480pt-wide row when placed in the body, vs. ~460pt wide - // starting at x≈30 when placed here). A Section's footer is - // rendered as plain full-width content below that grid, not as a - // grid row, so it never gets pulled into the label/control - // layout - which is why moving this block here (rather than - // tweaking the Slider itself yet again) actually fixes the width - // and alignment, and also guarantees the three rows below line - // up with each other since they're now plain VStack siblings - // sharing one container instead of being split across the grid. + // 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(draftPreset.title) + Text(sliderPreset.title) .fontWeight(.semibold) .foregroundStyle(Color.accentColor) } - Slider(value: draftPresetSliderValue, + Slider(value: $sliderValue, in: 0...Double(HistoryRetentionPreset.allCases.count - 1), - step: 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 == draftPreset ? .bold : .medium)) - .foregroundStyle(preset == draftPreset ? Color.accentColor : .secondary) + .font(.system(size: 10, weight: preset == sliderPreset ? .bold : .medium)) + .foregroundStyle(preset == sliderPreset ? Color.accentColor : .secondary) .frame(maxWidth: .infinity) } } @@ -360,6 +340,7 @@ private struct HistoryRetentionSettings: View { .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() } @@ -398,6 +379,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/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 6169f20..cbd3ac9 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -135,10 +135,9 @@ final class ClipboardStore { return max(0, history.count - max(20, limit)) case .timeInterval: // days == 0 means "forever" - no age-based cutoff, just the safety cap. - guard days > 0 else { + guard let cutoff = Self.retentionCutoff(daysAgo: days) else { return max(0, history.count - Self.timeRetentionSafetyCap) } - let cutoff = Self.retentionCutoff(daysAgo: days) let byAge = history.filter { $0.createdAt < cutoff }.count return byAge + max(0, (history.count - byAge) - Self.timeRetentionSafetyCap) } @@ -146,8 +145,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 = [] @@ -166,9 +166,7 @@ final class ClipboardStore { } case .timeInterval: // days == 0 means "forever" - skip the age cutoff, keep the safety cap. - let days = Settings.shared.historyRetentionDays - if days > 0 { - let cutoff = Self.retentionCutoff(daysAgo: days) + if let cutoff = Self.retentionCutoff(daysAgo: Settings.shared.historyRetentionDays) { let old = history.filter { $0.createdAt < cutoff } if !old.isEmpty { removed += old From 61e8240ee70f75a3dc8d840ff0226dcd1507eedb Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 8 Oct 2026 05:01:13 +0300 Subject: [PATCH 10/23] Add a selected-clip position preference A "Clip Navigation" section in Settings chooses where the selected clip parks in the strip: centered, as before, or at the right edge the way Paste does. The bar's scroll-to-selection reads the preference for its anchor and re-scrolls when the preference changes while a clip is selected. --- Sources/Pesty/Settings/Settings.swift | 30 ++++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 12 +++++++++ Sources/Pesty/UI/BarView.swift | 16 +++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 939851d..f79d911 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -138,6 +138,27 @@ enum HistoryRetentionPreset: Int, CaseIterable, Identifiable { } } +enum SelectedClipPosition: Int, CaseIterable, Identifiable { + case center + case rightEdge + + var id: Int { rawValue } + + var title: String { + switch self { + case .center: "Center" + case .rightEdge: "Right edge" + } + } + + var detail: String { + switch self { + case .center: "Keep the selected clip centered with surrounding context visible." + case .rightEdge: "Place the selected clip at the far right, like Paste." + } + } +} + @Observable @MainActor final class Settings { @@ -166,6 +187,7 @@ final class Settings { static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" static let cloudKitSync = "cloudKitSync" + static let selectedClipPosition = "selectedClipPosition" } var historyLimit: Int { @@ -281,6 +303,10 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(cloudKitSync, forKey: Keys.cloudKitSync) } } + var selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + private init() { d.register(defaults: [ Keys.historyLimit: 500, @@ -303,7 +329,8 @@ final class Settings { Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, - Keys.cloudKitSync: true + Keys.cloudKitSync: true, + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue ]) historyLimit = d.integer(forKey: Keys.historyLimit) historyRetentionMode = HistoryRetentionMode(rawValue: d.string(forKey: Keys.historyRetentionMode) ?? "") @@ -326,6 +353,7 @@ final class Settings { onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) cloudKitSync = d.bool(forKey: Keys.cloudKitSync) + selectedClipPosition = SelectedClipPosition(rawValue: d.integer(forKey: Keys.selectedClipPosition)) ?? .center isLoaded = true } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index d84a38c..025c394 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -147,6 +147,18 @@ private struct GeneralSettings: View { #endif } + 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( diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 382c5cb..583d01c 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -166,15 +166,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") From 4ed0909b7522df41e3f35e3ad2f843ff1ac7e5a2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 8 Oct 2026 05:01:23 +0300 Subject: [PATCH 11/23] Add clip color themes A "Clip Colors" section in Settings offers three palettes for the card headers: the per-source-app palette cards have always used, a boosted version of it with higher saturation and contrast, and Accent Shades, which derives ten deterministic shades from one user-picked base color (FNV-1a of the bundle identifier picks the shade, so an app keeps its shade across launches) with a live swatch preview. The boosted palette is the default here. --- Sources/Pesty/Settings/Settings.swift | 45 +++++++++++ Sources/Pesty/Settings/SettingsView.swift | 38 +++++++++ Sources/Pesty/Util/SourceColor.swift | 95 +++++++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index f79d911..d7eaac1 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -138,6 +138,37 @@ enum HistoryRetentionPreset: Int, CaseIterable, Identifiable { } } +enum ClipColorTheme: Int, CaseIterable, Identifiable { + // The stronger treatment is the default: side by side, the plain source + // color and its boosted version were close enough to be hard to tell + // apart, which made "Default" the wrong name for the quieter one. + // Classic keeps the unmodified color as the named alternative. + case `default` + case classic + case accentShades + + var id: Int { rawValue } + + var title: String { + switch self { + case .default: "Default" + case .classic: "Classic" + case .accentShades: "Accent shades" + } + } + + var detail: String { + switch self { + case .default: + "Use a stronger, higher-contrast version of each source app color." + case .classic: + "Match each clip to its source app\u{2019}s familiar card 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 @@ -187,6 +218,8 @@ final class Settings { static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" static let cloudKitSync = "cloudKitSync" + static let clipColorTheme = "clipColorTheme" + static let clipColorAccentHex = "clipColorAccentHex" static let selectedClipPosition = "selectedClipPosition" } @@ -303,6 +336,14 @@ 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) } } @@ -330,6 +371,8 @@ final class Settings { Keys.onboarded: false, Keys.iCloudSync: false, Keys.cloudKitSync: true, + Keys.clipColorTheme: ClipColorTheme.default.rawValue, + Keys.clipColorAccentHex: "#FF5A9F", Keys.selectedClipPosition: SelectedClipPosition.center.rawValue ]) historyLimit = d.integer(forKey: Keys.historyLimit) @@ -353,6 +396,8 @@ final class Settings { 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 } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 025c394..5c0efd0 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -147,6 +147,37 @@ private struct GeneralSettings: View { #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 @@ -236,6 +267,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 diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..582b4ac 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @MainActor @@ -22,7 +23,45 @@ 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 { + switch Settings.shared.clipColorTheme { + case .default: + return vibrantColor(from: paletteColor(for: bundleID)) + case .classic: + return paletteColor(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) } + } + + /// Upstream's original per-app color: a stable palette slot assigned on + /// first sight and persisted, so a given app keeps its color across launches. + private static func paletteColor(for bundleID: String?) -> Color { guard let id = bundleID, !id.isEmpty else { return palette[0] } if let i = map[id] { return palette[i % palette.count] } let i = map.count % palette.count @@ -30,4 +69,60 @@ enum SourceColor { UserDefaults.standard.set(map, forKey: key) return palette[i] } + + 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 func accentShade(variant: AccentVariant, accentHex: String) -> Color { + let nsColor = NSColor(hex: accentHex)?.usingColorSpace(.sRGB) + ?? NSColor.systemPink.usingColorSpace(.sRGB)! + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + + // Normalizing the middle brightness prevents very light or very dark + // user selections from collapsing several variants into the same color. + let middleBrightness = min(0.76, max(0.66, Double(brightness))) + let adjustedHue = (Double(hue) + variant.hueOffset + 1).truncatingRemainder(dividingBy: 1) + + return Color( + hue: adjustedHue, + saturation: min(0.98, max(0.60, Double(saturation) + variant.saturationOffset)), + brightness: min(0.98, max(0.32, middleBrightness + variant.brightnessOffset)) + ) + } + + private static func stableIndex(for bundleID: String, count: Int) -> Int { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in bundleID.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return Int(hash % UInt64(count)) + } + + private struct AccentVariant { + let hueOffset: Double + let saturationOffset: Double + let brightnessOffset: Double + } } From bd1e2cf03993fff7254dd71c1b8cce3a657df27d Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:01:33 +0300 Subject: [PATCH 12/23] Keep the existing palette as the default clip color theme The themes commit made the boosted palette the default, which would have recolored every card on update. Default is again the palette cards have always used, the boosted treatment is an opt-in theme named Vibrant, and Accent Shades stays opt-in, so the user-visible default is unchanged. The HSB conversion behind Vibrant ran per card per render. The twelve boosted colors are now computed once per palette slot, which is what a bundle id maps to, and Accent Shades reads the base color's HSB once per chosen color. Based on #94 by @alvst. --- Sources/Pesty/Settings/Settings.swift | 12 +++------ Sources/Pesty/Util/SourceColor.swift | 39 ++++++++++++++++++--------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index d7eaac1..810f33c 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -139,12 +139,8 @@ enum HistoryRetentionPreset: Int, CaseIterable, Identifiable { } enum ClipColorTheme: Int, CaseIterable, Identifiable { - // The stronger treatment is the default: side by side, the plain source - // color and its boosted version were close enough to be hard to tell - // apart, which made "Default" the wrong name for the quieter one. - // Classic keeps the unmodified color as the named alternative. case `default` - case classic + case vibrant case accentShades var id: Int { rawValue } @@ -152,7 +148,7 @@ enum ClipColorTheme: Int, CaseIterable, Identifiable { var title: String { switch self { case .default: "Default" - case .classic: "Classic" + case .vibrant: "Vibrant" case .accentShades: "Accent shades" } } @@ -160,9 +156,9 @@ enum ClipColorTheme: Int, CaseIterable, Identifiable { var detail: String { switch self { case .default: - "Use a stronger, higher-contrast version of each source app color." - case .classic: "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." } diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 582b4ac..e6073a2 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -42,9 +42,9 @@ enum SourceColor { static func color(for bundleID: String?) -> Color { switch Settings.shared.clipColorTheme { case .default: - return vibrantColor(from: paletteColor(for: bundleID)) - case .classic: - return paletteColor(for: bundleID) + return palette[paletteIndex(for: bundleID)] + case .vibrant: + return vibrantPalette[paletteIndex(for: bundleID)] case .accentShades: return accentShade( for: bundleID?.isEmpty == false ? bundleID! : "unknown", @@ -59,17 +59,19 @@ enum SourceColor { accentVariants.map { accentShade(variant: $0, accentHex: accentHex) } } - /// Upstream's original per-app color: a stable palette slot assigned on - /// first sight and persisted, so a given app keeps its color across launches. - private static func paletteColor(for bundleID: String?) -> Color { - guard let id = bundleID, !id.isEmpty else { return palette[0] } - if let i = map[id] { return palette[i % palette.count] } + /// 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 @@ -91,22 +93,33 @@ enum SourceColor { return accentShade(variant: accentVariants[index], accentHex: accentHex) } - private static func accentShade(variant: AccentVariant, accentHex: String) -> Color { + 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, Double(brightness))) - let adjustedHue = (Double(hue) + variant.hueOffset + 1).truncatingRemainder(dividingBy: 1) + 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, Double(saturation) + variant.saturationOffset)), + saturation: min(0.98, max(0.60, base.saturation + variant.saturationOffset)), brightness: min(0.98, max(0.32, middleBrightness + variant.brightnessOffset)) ) } From 4b1d814890be92a3a0164f22e6084022372ce0fb Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:09:13 +0300 Subject: [PATCH 13/23] Show image previews on cards and real icons on file clips Image clips, and file clips that point at one image file, draw the picture edge to edge over a transparency checkerboard instead of a padded thumbnail. The bitmap no longer comes from NSImage(contentsOf:) inside the view body: ClipPreviewProvider decodes the stored PNG downsampled off the main actor, asks Quick Look for image files, and keeps the results in an NSCache bounded by count and byte cost. The header of a multi-file clip reads "5 files" rather than "File". Other file clips show the file's Finder icon, resolved in the card's task and cached, with a "File not found" caption when stat fails with ENOENT. The caption is never shown in the sandboxed App Store build, where access to a copied file does not survive a relaunch and the stat fails for files that are still there. The full-bleed treatment, the checkerboard view and the file-count header come from #96 by @alvst. Fixes #102. --- Sources/Pesty/UI/ClipCardView.swift | 132 ++++++++++++--- Sources/Pesty/UI/Theme.swift | 1 + Sources/Pesty/Util/ClipPreviewProvider.swift | 159 ++++++++++++++++++ .../PestyTests/ClipPreviewProviderTests.swift | 33 ++++ 4 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 Sources/Pesty/Util/ClipPreviewProvider.swift create mode 100644 Tests/PestyTests/ClipPreviewProviderTests.swift diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 09b71cc..b94ac79 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,7 +1,6 @@ import AppKit import ImageIO import SwiftUI -import UniformTypeIdentifiers struct ClipCardView: View { let item: ClipItem @@ -9,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) } @@ -48,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 { @@ -55,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) @@ -72,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)) @@ -92,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) @@ -119,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) @@ -144,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) @@ -181,15 +245,8 @@ struct ClipCardView: View { .padding(.top, 8) } - /// Matched on the path extension rather than by loading the file: this is - /// evaluated on every card render, so it must not touch disk. private var isSingleImageFile: Bool { - 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) else { return false } - return type.conforms(to: .image) + ClipPreviewProvider.imageFileURL(for: item) != nil } /// NSHomeDirectory is the sandbox container in the App Store build, so the @@ -354,6 +411,31 @@ struct ClipCardView: View { } } +/// 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.. = { + 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/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) + } +} From 3b527913d1ecdbedaf1105d35f48dbc932fc9bd7 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 15:01:03 -0700 Subject: [PATCH 14/23] Copy the selected clip with Command-C - Add a Copied to Clipboard toast, shown after every successful copy (Command-C and the context menu's Copy action both go through copyItem). - Promote the copied clip to the front of history, since copying an existing clip back to the pasteboard is a deliberate re-use of it and the clipboard monitor correctly ignores Pesty's own pasteboard writes. --- Sources/Pesty/AppController.swift | 13 ++++ Sources/Pesty/Store/ClipboardStore.swift | 10 +++ Sources/Pesty/UI/CopyToastController.swift | 74 ++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 Sources/Pesty/UI/CopyToastController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 3dbc16e..4333bc0 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? @@ -324,11 +325,21 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } func copyItem(_ item: ClipItem) { + let previousChange = NSPasteboard.general.changeCount let change = PasteService.copy(item) monitor.suppressUntilChangeCount = change + if change != previousChange { + store.promoteCopiedItem(item) + } // Tink, not Pop: copy and paste stay audibly distinct. if Settings.shared.playSoundOnCopy { NSSound(named: "Tink")?.play() } hideBar() + copyToast.show() + } + + func copySelected() { + guard let item = store.selectedItem else { return } + copyItem(item) } var pasteMenuTitle: String { @@ -558,6 +569,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { case kVK_ForwardDelete: deleteEffectiveSelection() return nil + case kVK_ANSI_C: + if cmd { copySelected(); return nil } default: break } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index cbd3ac9..9324a48 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -127,6 +127,16 @@ 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) { + var copied = item + copied.createdAt = date + addCaptured(copied) + } + func applyRetentionPolicy() { trimHistory(); scheduleSave() } func retentionRemovalCount(mode: HistoryRetentionMode, limit: Int, days: Int) -> Int { diff --git a/Sources/Pesty/UI/CopyToastController.swift b/Sources/Pesty/UI/CopyToastController.swift new file mode 100644 index 0000000..147a86a --- /dev/null +++ b/Sources/Pesty/UI/CopyToastController.swift @@ -0,0 +1,74 @@ +import AppKit +import SwiftUI + +@MainActor +final class CopyToastController { + private let panel: NSPanel + private var dismissWorkItem: DispatchWorkItem? + + init() { + 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 = NSHostingView(rootView: CopyToastView()) + } + + func show() { + dismissWorkItem?.cancel() + let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return } + + 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() + 1.25, 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 { + var body: some View { + HStack(spacing: 9) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Theme.selection) + Text("Copied to Clipboard") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial, in: Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .strokeBorder(.white.opacity(0.28)) + } + } +} From 0f59f7e7d56038080e91d80d6e18f579f010991a Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:17:51 +0300 Subject: [PATCH 15/23] Promote copied clips without borrowing a Pinboard identity promoteCopiedItem went through addCaptured, which inserts the clip it is handed whenever history has no same-content twin. Copying a Pinboard card whose history twin had been deleted or trimmed therefore put the pinboard's own UUID into history, an identity the two containers are never meant to share: the App Store sync keys records by UUID and lets the pinboard record win, so that history copy would never upload, and a later remote apply of the pinboard record would evict it. With no twin, history now receives an independent copy, minted the same way saveToPinboard mints one, with its own UUID and image file. The "Copied to Clipboard" toast shows only when the pasteboard actually changed; PasteService.copy leaves it untouched for an image whose file is missing. Command-C requires exactly Command, so Command-Shift-C and the other combinations fall through as before. Based on #82 by @alvst. --- Sources/Pesty/AppController.swift | 6 ++++-- Sources/Pesty/Store/ClipboardStore.swift | 25 +++++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 4333bc0..2ccb743 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -328,13 +328,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { 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() - copyToast.show() } func copySelected() { @@ -570,7 +572,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { deleteEffectiveSelection() return nil case kVK_ANSI_C: - if cmd { copySelected(); return nil } + if cmd, !ctrl, !opt, !flags.contains(.shift) { copySelected(); return nil } default: break } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 9324a48..3822130 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -132,9 +132,15 @@ final class ClipboardStore { /// 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) { - var copied = item - copied.createdAt = date - addCaptured(copied) + 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() } @@ -281,7 +287,13 @@ 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, @@ -293,10 +305,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? { From 479bacde0c6ebf9a98093c0848b86b3fe7689777 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 8 Oct 2026 05:18:13 +0300 Subject: [PATCH 16/23] Add Clean Formatting and Markdown paste options Two new context-menu paste modes alongside Paste as Plain Text, enabled whenever a clip has rich content. Clean Formatting keeps bold, italic, underline, strikethrough, and links while normalizing fonts, sizes, and colors to the system default. Markdown converts the rich content to Markdown text, splitting style markers across line breaks so the output stays valid, and rendering links as [text](url). The clipboard monitor now also captures a copy's HTML flavor - browser copies often carry HTML with no RTF - without changing how clips are classified. Conversions prefer HTML, then RTF, then fall back to plain text. Paste plumbing moves from an asPlainText flag to a PasteFormat enum carrying all four modes. The HTML importer behind the Clean Formatting and Markdown conversions is WebKit-backed: handed raw captured HTML, it fetches remote subresources - tracking pixels included - synchronously on the main thread at paste time, breaking the no-network-calls promise. Strip every element that can reference an external resource (img, picture, source, iframe, object, embed, link, script, style, svg, video, audio, and CSS url() values) before conversion; the converters only keep text plus bold/italic/underline/links, so nothing representable is lost. A 2-second importer timeout backs the stripping up, and sources over 1MB skip rich conversion entirely, falling back to plain text so a pathological clip can't hitch the paste. Capture-side, cap stored pasteboard HTML at the sync layer's inline payload bound (CKSchema.inlineLimit) so every routine text copy can't persist an unbounded HTML blob; oversized HTML is dropped and the conversions fall back to RTF/plain text as before. --- Sources/Pesty/AppController.swift | 6 +- Sources/Pesty/Models/ClipItem.swift | 3 + Sources/Pesty/Monitor/ClipboardMonitor.swift | 11 +- Sources/Pesty/Monitor/PasteService.swift | 56 ++++++- Sources/Pesty/UI/ClipCardView.swift | 12 +- Sources/Pesty/Util/FormatConverter.swift | 153 +++++++++++++++++++ 6 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 Sources/Pesty/Util/FormatConverter.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 2ccb743..e7f217a 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -318,10 +318,10 @@ 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) + PasteService.paste(item, into: target, monitor: monitor, format: format) } func copyItem(_ item: ClipItem) { @@ -523,7 +523,7 @@ 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 } 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..9698f4e 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -99,6 +99,15 @@ 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. Cap it at the same bound the sync layer uses + // for inline text/rtf payloads (CKSchema.inlineLimit) so a routine + // copy can't persist an unbounded HTML blob with every clip — + // oversized HTML is simply dropped and the paste conversions fall + // back to RTF/plain text as they did before HTML capture existed. + var html = pasteboard.data(forType: .html) + if let data = html, data.count > CKSchema.inlineLimit { 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..765e02d 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() } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index b94ac79..7a367a6 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -299,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") } diff --git a/Sources/Pesty/Util/FormatConverter.swift b/Sources/Pesty/Util/FormatConverter.swift new file mode 100644 index 0000000..a0a003c --- /dev/null +++ b/Sources/Pesty/Util/FormatConverter.swift @@ -0,0 +1,153 @@ +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 + } + + /// Removes every element that could make the HTML importer touch the + /// network. `NSAttributedString(html:)` is WebKit-backed and will fetch + /// remote subresources — `` tracking pixels included — + /// synchronously, on the main thread, at paste time. That would break the + /// app's no-network-calls promise and stall the paste, so the offending + /// elements are dropped wholesale before conversion: the converters only + /// keep text plus bold/italic/underline/links, and images, scripts, and + /// stylesheets aren't representable in the output anyway. + private static func sanitizedHTML(_ data: Data) -> Data? { + guard let raw = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16) else { return nil } + var html = raw + + func drop(_ pattern: String) { + html = html.replacingOccurrences(of: pattern, with: "", + options: [.regularExpression, .caseInsensitive]) + } + + // Container elements go with their entire contents… + for tag in ["script", "style", "iframe", "object", "picture", "svg", "video", "audio"] { + drop("<\(tag)\\b[^>]*>[\\s\\S]*?") + // …and any unbalanced leftovers of the same tags. + drop("<\(tag)\\b[^>]*/?>") + drop("") + } + // Void/self-closing elements that reference external resources. + for tag in ["img", "source", "embed", "link"] { + drop("<\(tag)\\b[^>]*/?>") + } + // Neutralize CSS url(...) in surviving style attributes + // (background images are fetched too). + html = html.replacingOccurrences(of: "url\\s*\\([^)]*\\)", with: "none", + options: [.regularExpression, .caseInsensitive]) + return html.data(using: .utf8) + } + + private static func attributed(for item: ClipItem) -> NSAttributedString? { + if let html = item.htmlData, withinLimit(html), + let safe = sanitizedHTML(html), + let s = NSAttributedString(html: safe, + // Belt-and-braces alongside the stripping + // above: never let the importer wait on a + // (now nonexistent) subresource load. + 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 + } +} From c7cdf8a3578eed20b6cfae358787ffc21d652cb1 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:19:58 +0300 Subject: [PATCH 17/23] Convert captured HTML as is and keep it only where RTF is missing The regex stripping that ran before the HTML importer rewrote visible text along with markup: every url(...) in the clip's own words, a copied CSS snippet or the URL("https://...") call in a code sample, pasted as "none" through Clean Formatting and Markdown. It also guarded against something the importer does not do. NSAttributedString(html:) loads no subresources at all: a local HTTP server logged no request for an unsanitized img, input type=image, body background, image-set() or meta refresh. The HTML now reaches the importer as captured; the 2 s document timeout and the 1 MB conversion cap stay. Editing a clip replaced its text and RTF but kept the captured HTML, which the converters prefer, so Paste as Markdown after an edit produced the pre-edit words. Both edit paths clear it. HTML is captured only when the pasteboard carries no RTF, the browser case the conversions need it for, and only up to 64 KB: store.json is re-encoded and rewritten after every capture, and Safari copies already carry RTF. The independent copies made for Pinboards and for history promotions carry the HTML along. Based on #93 by @alvst. --- Sources/Pesty/Monitor/ClipboardMonitor.swift | 14 +++---- Sources/Pesty/Store/ClipboardStore.swift | 3 ++ Sources/Pesty/Util/FormatConverter.swift | 44 ++------------------ 3 files changed, 13 insertions(+), 48 deletions(-) diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 9698f4e..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? @@ -101,13 +102,12 @@ 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. Cap it at the same bound the sync layer uses - // for inline text/rtf payloads (CKSchema.inlineLimit) so a routine - // copy can't persist an unbounded HTML blob with every clip — - // oversized HTML is simply dropped and the paste conversions fall - // back to RTF/plain text as they did before HTML capture existed. - var html = pasteboard.data(forType: .html) - if let data = html, data.count > CKSchema.inlineLimit { html = nil } + // 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 diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 3822130..20d2bd9 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -298,6 +298,7 @@ final class ClipboardStore { type: item.type, text: item.text, rtfData: item.rtfData, + htmlData: item.htmlData, imageFileName: item.imageFileName, imageHash: item.imageHash, fileURLs: item.fileURLs, @@ -325,6 +326,7 @@ final class ClipboardStore { updated.type = type updated.text = text updated.rtfData = richTextData + updated.htmlData = nil updated.colorHex = nil return updated } @@ -339,6 +341,7 @@ final class ClipboardStore { updated.type = .color updated.text = nil updated.rtfData = nil + updated.htmlData = nil updated.colorHex = normalized return updated } diff --git a/Sources/Pesty/Util/FormatConverter.swift b/Sources/Pesty/Util/FormatConverter.swift index a0a003c..72a11a0 100644 --- a/Sources/Pesty/Util/FormatConverter.swift +++ b/Sources/Pesty/Util/FormatConverter.swift @@ -18,49 +18,11 @@ enum FormatConverter { return !data.isEmpty && data.count <= maxRichSourceBytes } - /// Removes every element that could make the HTML importer touch the - /// network. `NSAttributedString(html:)` is WebKit-backed and will fetch - /// remote subresources — `` tracking pixels included — - /// synchronously, on the main thread, at paste time. That would break the - /// app's no-network-calls promise and stall the paste, so the offending - /// elements are dropped wholesale before conversion: the converters only - /// keep text plus bold/italic/underline/links, and images, scripts, and - /// stylesheets aren't representable in the output anyway. - private static func sanitizedHTML(_ data: Data) -> Data? { - guard let raw = String(data: data, encoding: .utf8) - ?? String(data: data, encoding: .utf16) else { return nil } - var html = raw - - func drop(_ pattern: String) { - html = html.replacingOccurrences(of: pattern, with: "", - options: [.regularExpression, .caseInsensitive]) - } - - // Container elements go with their entire contents… - for tag in ["script", "style", "iframe", "object", "picture", "svg", "video", "audio"] { - drop("<\(tag)\\b[^>]*>[\\s\\S]*?") - // …and any unbalanced leftovers of the same tags. - drop("<\(tag)\\b[^>]*/?>") - drop("") - } - // Void/self-closing elements that reference external resources. - for tag in ["img", "source", "embed", "link"] { - drop("<\(tag)\\b[^>]*/?>") - } - // Neutralize CSS url(...) in surviving style attributes - // (background images are fetched too). - html = html.replacingOccurrences(of: "url\\s*\\([^)]*\\)", with: "none", - options: [.regularExpression, .caseInsensitive]) - return html.data(using: .utf8) - } - 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 safe = sanitizedHTML(html), - let s = NSAttributedString(html: safe, - // Belt-and-braces alongside the stripping - // above: never let the importer wait on a - // (now nonexistent) subresource load. + let s = NSAttributedString(html: html, options: [.timeout: 2.0], documentAttributes: nil) { return s From d1062422ff2556d659e5a73e9f607b3c613561d8 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 15:01:44 -0700 Subject: [PATCH 18/23] Replace the search indicator with a real editable field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous search UI was a synthetic pill built from captured keystrokes — appended-only, no cursor, no click-to-edit, no text selection or IME support. NativeBarSearchField is a real NSTextField edited through the normal AppKit responder chain (kept mounted even in its compact zero-width state, so the bar's key monitor can focus it synchronously and hand off the very first keystroke without losing it). BarInputMode tracks whether focus belongs to the field or the clip strip, so the monitor and the field agree on who owns a given key without probing the first-responder chain from every call site. --- Sources/Pesty/AppController.swift | 104 ++++++++++++++- Sources/Pesty/Store/ClipboardStore.swift | 10 ++ Sources/Pesty/UI/BarSearchField.swift | 141 +++++++++++++++++++++ Sources/Pesty/UI/BarView.swift | 62 +++++++-- Sources/Pesty/UI/BarWindowController.swift | 16 ++- 5 files changed, 312 insertions(+), 21 deletions(-) create mode 100644 Sources/Pesty/UI/BarSearchField.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index e7f217a..ca0a108 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -298,6 +298,47 @@ 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() + } + + /// Return leaves the query intact and hands arrows/shortcuts back to the + /// clip strip. With no result there is nowhere to move, so search keeps + /// focus instead. + func submitBarSearch() { + guard store.barInputMode == .search, !store.visibleItems.isEmpty else { return } + barController?.resignSearch() + store.barInputMode = .cards + } + + 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) @@ -509,6 +550,14 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // monitor is only responsible for keys delivered to the panel itself. guard event.window === barController?.window else { return event } + // The native search field owns the entire event while it is editing: + // arrows, selection, clipboard commands, deletion, spaces, keyboard + // layouts, and composed text all need real AppKit text-editing + // behavior, not this monitor's clip-navigation shortcuts. + if barController?.searchOwnsFirstResponder == true { + return event + } + if handleBarCommandShortcut(event) { return nil } let code = Int(event.keyCode) @@ -551,7 +600,16 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { store.moveSelection(by: 1); return nil case kVK_Delete: if cmd { deleteEffectiveSelection(); 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 @@ -577,12 +635,19 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { 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 @@ -601,6 +666,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/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 20d2bd9..35c6b5a 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 { @@ -21,6 +30,7 @@ final class ClipboardStore { var source: BarSource = .history { didSet { if source != oldValue { clearMultiSelection() } } } + var barInputMode: BarInputMode = .cards var searchText: String = "" { didSet { if searchText != oldValue { clearMultiSelection() } } } diff --git a/Sources/Pesty/UI/BarSearchField.swift b/Sources/Pesty/UI/BarSearchField.swift new file mode 100644 index 0000000..c5f0abf --- /dev/null +++ b/Sources/Pesty/UI/BarSearchField.swift @@ -0,0 +1,141 @@ +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. +/// +/// Ported from Pesty-Alvie's `BarSearchField.swift`, which replaced the +/// previous approach — a global keyDown monitor that appended characters +/// directly to `store.searchText` — with a real `NSTextField` edited through +/// the normal AppKit responder chain. The append-only approach had no actual +/// cursor: arrow keys always moved clip selection, never a caret inside the +/// query, and mid-string edits/selection/IME composition were impossible. +@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 + + 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) + // The chrome bar is dark, unlike Pesty-Alvie's light card styling — + // 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 + } + return false + } + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 583d01c..30c0ac9 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 @@ -64,17 +76,35 @@ 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.submitBarSearch() }, + onCancel: { AppController.shared.cancelBarSearchOrHide() } + ) + .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 +112,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 { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 663be06..957378c 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -33,6 +33,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { private var phase: Phase = .hidden private var epoch = 0 + private let searchBridge = BarSearchFieldBridge() /// True while the bar is up or on its way up. `AppController.toggleBar` asks this /// instead of `window.isVisible`. @@ -68,7 +69,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 @@ -223,6 +224,19 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { window?.orderOut(nil) } + var searchOwnsFirstResponder: Bool { + searchBridge.ownsFirstResponder(in: window) + } + + @discardableResult + func focusSearchAtEnd() -> Bool { + searchBridge.focusAtEnd() + } + + func resignSearch() { + searchBridge.resign() + } + func windowDidResignKey(_ notification: Notification) { guard Settings.shared.hideOnClickOutside, phase == .shown, From b66d10a5c68ab5affde4c6c16cbd1668d3b8505d Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:22:02 +0300 Subject: [PATCH 19/23] Keep Return, quick paste and the arrows working while typing a search The field took every key while it had focus. Return resigned it and handed focus back to the cards instead of pasting, so the open, type, Return flow needed two Returns; Command-1 to Command-9 went to the field editor, which has no binding for them; and Up, Down, Left and Right moved a caret inside a one-line query instead of the selection. Return now pastes the selected clip from the field's delegate, the quick-paste digits are handled before the field takes the event, and the delegate routes Up and Down, plus Left and Right once the caret sits at the matching end of the query, to the strip's selection. A hide while the field was focused (Escape on an empty query, the hotkey, a click outside) left it first responder of the ordered-out panel, so the next presentation came up in search mode: pill expanded, first Return swallowed, arrows moving the caret. showBar resigns the field and resets the input mode before anything else. Two comments described another project's version of this file and are gone. Based on #85 by @alvst. --- Sources/Pesty/AppController.swift | 32 ++++++++++++------------- Sources/Pesty/UI/BarSearchField.swift | 34 ++++++++++++++++++++------- Sources/Pesty/UI/BarView.swift | 5 ++-- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ca0a108..af37e99 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -280,6 +280,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() @@ -314,15 +320,6 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { store.selectFirst() } - /// Return leaves the query intact and hands arrows/shortcuts back to the - /// clip strip. With no result there is nowhere to move, so search keeps - /// focus instead. - func submitBarSearch() { - guard store.barInputMode == .search, !store.visibleItems.isEmpty else { return } - barController?.resignSearch() - store.barInputMode = .cards - } - func clearBarSearch() { let hadQuery = !store.searchText.isEmpty barController?.resignSearch() @@ -550,14 +547,6 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // monitor is only responsible for keys delivered to the panel itself. guard event.window === barController?.window else { return event } - // The native search field owns the entire event while it is editing: - // arrows, selection, clipboard commands, deletion, spaces, keyboard - // layouts, and composed text all need real AppKit text-editing - // behavior, not this monitor's clip-navigation shortcuts. - if barController?.searchOwnsFirstResponder == true { - return event - } - if handleBarCommandShortcut(event) { return nil } let code = Int(event.keyCode) @@ -577,6 +566,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { 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 { diff --git a/Sources/Pesty/UI/BarSearchField.swift b/Sources/Pesty/UI/BarSearchField.swift index c5f0abf..eba542b 100644 --- a/Sources/Pesty/UI/BarSearchField.swift +++ b/Sources/Pesty/UI/BarSearchField.swift @@ -4,13 +4,6 @@ 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. -/// -/// Ported from Pesty-Alvie's `BarSearchField.swift`, which replaced the -/// previous approach — a global keyDown monitor that appended characters -/// directly to `store.searchText` — with a real `NSTextField` edited through -/// the normal AppKit responder chain. The append-only approach had no actual -/// cursor: arrow keys always moved clip selection, never a caret inside the -/// query, and mid-string edits/selection/IME composition were impossible. @MainActor final class BarSearchFieldBridge { weak var field: NSTextField? @@ -57,6 +50,7 @@ struct NativeBarSearchField: NSViewRepresentable { let onEnd: () -> Void let onSubmit: () -> Void let onCancel: () -> Void + let onMoveSelection: (Int) -> Void func makeCoordinator() -> Coordinator { Coordinator(parent: self) @@ -74,8 +68,7 @@ struct NativeBarSearchField: NSViewRepresentable { field.lineBreakMode = .byTruncatingTail field.placeholderString = "Search" field.font = .systemFont(ofSize: 13, weight: .medium) - // The chrome bar is dark, unlike Pesty-Alvie's light card styling — - // matches Theme.chromeTextPrimary (Color.white.opacity(0.95)). + // 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( @@ -135,6 +128,29 @@ struct NativeBarSearchField: NSViewRepresentable { 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 30c0ac9..df9572c 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -90,8 +90,9 @@ struct BarView: View { bridge: searchBridge, onBegin: { AppController.shared.setBarSearchEditing(true) }, onEnd: { AppController.shared.setBarSearchEditing(false) }, - onSubmit: { AppController.shared.submitBarSearch() }, - onCancel: { AppController.shared.cancelBarSearchOrHide() } + onSubmit: { AppController.shared.pasteSelected() }, + onCancel: { AppController.shared.cancelBarSearchOrHide() }, + onMoveSelection: { store.moveSelection(by: $0) } ) .frame(minWidth: searchIsActive ? 120 : 0, idealWidth: searchIsActive ? 180 : 0, From 4cf9eb674ab2f74f53ddffe04aec5d70990e9507 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 8 Oct 2026 05:22:32 +0300 Subject: [PATCH 20/23] Add a live-resize handle for the Paste Bar - BarResizeGeometry centralizes bar-height sizing rules (default/min/max, per-display clamping, and the macOS 26 glass content frame math) shared by both the persisted preference and the live drag session. - BarResizeHandle is a compact AppKit drag target (with accessibility increment/decrement support) shown in an optional overlay at the top of the bar. - A new "Show resize handle on the Paste Bar" Settings toggle gates it, off by default. The bar's own key monitor steps aside while the handle owns first responder, so Escape/arrows during a drag reach it directly. The height slider in Settings previews the same size. Clicking into Settings dismisses the bar, so a pixel count was previously the only feedback for a dimension that is only meaningful on screen. Dragging the slider now live-resizes the real bar when it happens to be up, and otherwise raises BarHeightGhostController: a translucent, click-through panel occupying exactly the frame the bar would take on the display under the pointer, drawing a faded real BarView behind a dashed outline and a large "N px" readout, and retiring itself about 1.1s after the last change. Showing the bar hides any outline still lingering. The bar-height slider's unit label reads px instead of pt. --- Sources/Pesty/AppController.swift | 43 +++- Sources/Pesty/Settings/Settings.swift | 23 +- Sources/Pesty/Settings/SettingsView.swift | 4 + .../Pesty/UI/BarHeightGhostController.swift | 95 ++++++++ Sources/Pesty/UI/BarResizeGeometry.swift | 116 ++++++++++ Sources/Pesty/UI/BarResizeHandle.swift | 218 ++++++++++++++++++ Sources/Pesty/UI/BarView.swift | 11 + Sources/Pesty/UI/BarWindowController.swift | 82 ++++++- 8 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 Sources/Pesty/UI/BarHeightGhostController.swift create mode 100644 Sources/Pesty/UI/BarResizeGeometry.swift create mode 100644 Sources/Pesty/UI/BarResizeHandle.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index af37e99..d038899 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -17,6 +17,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { private var previewWindow: NSWindow? private var previewedItemID: UUID? private var keyMonitor: Any? + private let barHeightGhost = BarHeightGhostController() private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? @@ -237,6 +238,41 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { ClipboardStore.shared.setICloudSync(enabling) } + func updateConfiguredBarHeight() { + barController?.applyConfiguredBarHeight() + } + + /// Feedback for the Settings height slider: resize the real bar when it is + /// up, and otherwise outline the proposed size where the bar would appear. + func previewBarHeight(_ height: Double) { + if barController?.isPresented == true { + barHeightGhost.hide() + updateConfiguredBarHeight() + } else { + barHeightGhost.show(height: height) + } + } + + 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() @@ -295,6 +331,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { if barController == nil || barController?.window == nil { barController = BarWindowController() } + // The real bar supersedes any height outline still lingering. + barHeightGhost.hide() barController?.show() startKeyMonitor() } @@ -545,7 +583,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 } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 810f33c..6c9689a 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -210,6 +210,7 @@ final class Settings { static let ignoreConcealed = "ignoreConcealed" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -306,12 +307,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 } @@ -362,7 +370,8 @@ final class Settings { Keys.playSoundOnCopy: false, Keys.ignoreConcealed: true, Keys.ignoredSourceAppBundleIDs: [], - Keys.barHeight: 430.0, + Keys.barHeight: BarResizeGeometry.defaultHeight, + Keys.showBarResizeHandle: false, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, @@ -387,7 +396,10 @@ final class Settings { ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) 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) @@ -396,6 +408,9 @@ final class Settings { 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 5c0efd0..a2e77cc 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -141,6 +141,7 @@ private struct GeneralSettings: View { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") 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) @@ -250,6 +251,9 @@ private struct GeneralSettings: View { } } .formStyle(.grouped) + .onChange(of: settings.barHeight) { _, height in + AppController.shared.previewBarHeight(height) + } #if !MAS .onAppear { accessibilityGranted = AXIsProcessTrusted() } .onReceive(poll) { _ in diff --git a/Sources/Pesty/UI/BarHeightGhostController.swift b/Sources/Pesty/UI/BarHeightGhostController.swift new file mode 100644 index 0000000..5d3e4e7 --- /dev/null +++ b/Sources/Pesty/UI/BarHeightGhostController.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +/// A translucent stand-in for the Paste Bar, shown while the height slider in +/// Settings moves. Clicking into Settings dismisses the bar, so a number of +/// pixels is otherwise the only feedback available for a size that is only +/// meaningful on screen. +@MainActor +final class BarHeightGhostController { + private static let lingerAfterChange: TimeInterval = 1.1 + + private var panel: NSPanel? + private var hideWork: DispatchWorkItem? + + func show(height: Double) { + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main ?? NSScreen.screens.first else { return } + + let panel = panel ?? makePanel() + self.panel = panel + + // Sized by the same rules the real bar uses, so the outline cannot + // promise a height the display would not actually give. + let frame = BarResizeGeometry.panelFrame(for: height, in: screen.visibleFrame) + panel.setFrame(frame, display: true) + (panel.contentView as? NSHostingView)?.rootView = + BarHeightGhostView(height: frame.height) + // orderFrontRegardless keeps Settings key: this is a readout, and + // stealing focus mid-drag would end the drag. + panel.orderFrontRegardless() + + hideWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.hide() } + hideWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.lingerAfterChange, execute: work) + } + + func hide() { + hideWork?.cancel() + hideWork = nil + panel?.orderOut(nil) + } + + private func makePanel() -> NSPanel { + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isFloatingPanel = true + panel.level = .modalPanel + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isMovable = false + // Purely a readout: it must never swallow a click meant for whatever + // it happens to cover. + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: BarHeightGhostView(height: 0)) + return panel + } +} + +private struct BarHeightGhostView: View { + let height: CGFloat + + private var shape: RoundedCorners { + RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight]) + } + + var body: some View { + ZStack { + // The real bar, faded: judging a height is much easier against + // actual cards than against an empty rectangle. + BarView(searchBridge: BarSearchFieldBridge()) + .allowsHitTesting(false) + .opacity(0.55) + shape.stroke(Theme.selection, style: StrokeStyle(lineWidth: 2, dash: [9, 7])) + VStack(spacing: 3) { + Text("\(Int(height)) px") + .font(.system(size: 30, weight: .semibold, design: .rounded)) + Text("Paste Bar height") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.75)) + } + .foregroundStyle(.white) + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background(Color.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.45), radius: 10, y: 2) + } + .ignoresSafeArea() + } +} 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..89a5ce2 --- /dev/null +++ b/Sources/Pesty/UI/BarResizeHandle.swift @@ -0,0 +1,218 @@ +import AppKit +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, event.keyCode == 53 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 window?.firstResponder === self { + window?.makeFirstResponder(previousFirstResponder) + } + 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/BarView.swift b/Sources/Pesty/UI/BarView.swift index df9572c..0bab5a2 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -26,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() } diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 957378c..8ac03ae 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -34,6 +34,8 @@ 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`. @@ -146,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: @@ -185,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) } @@ -194,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) @@ -219,6 +229,7 @@ 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) @@ -237,6 +248,69 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { 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) + } + + func applyConfiguredBarHeight() { + guard phase == .shown, resizeSession == nil, + let visibleFrame = targetVisibleFrame else { return } + applyResizeFrame(BarResizeGeometry.panelFrame( + for: Settings.shared.barHeight, + in: visibleFrame + )) + } + + 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, From c25905afa7f83f926f0d3e6cffdbfd6289c16281 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:24:11 +0300 Subject: [PATCH 21/23] Keep the resize handle and drop the height ghost The Settings slider previewed its value with a second panel: a click-through window at modal-panel level carrying a faded live BarView, a dashed outline and a readout, raised on every slider stop. On a laptop display it covers the Settings window it is meant to serve, and it was the one part of the change the new toggle did not gate. The panel, the slider wiring and the live resize it drove are gone; the handle on the bar stays, off by default behind its toggle. The slider label reads pt again: the value is in points (430 pt is 860 px on a Retina display), which the first of the two commits had right. After a drag the handle restored the previous first responder and kept the keys when that failed, leaving the bar's key monitor stepping aside for good; the window itself now takes first responder when the restore is refused. Escape is named by its Carbon constant. Based on #87 by @alvst. --- Sources/Pesty/AppController.swift | 18 ---- Sources/Pesty/Settings/SettingsView.swift | 5 +- .../Pesty/UI/BarHeightGhostController.swift | 95 ------------------- Sources/Pesty/UI/BarResizeHandle.swift | 10 +- Sources/Pesty/UI/BarWindowController.swift | 9 -- 5 files changed, 8 insertions(+), 129 deletions(-) delete mode 100644 Sources/Pesty/UI/BarHeightGhostController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d038899..4c5754c 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -17,7 +17,6 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { private var previewWindow: NSWindow? private var previewedItemID: UUID? private var keyMonitor: Any? - private let barHeightGhost = BarHeightGhostController() private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? @@ -238,21 +237,6 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { ClipboardStore.shared.setICloudSync(enabling) } - func updateConfiguredBarHeight() { - barController?.applyConfiguredBarHeight() - } - - /// Feedback for the Settings height slider: resize the real bar when it is - /// up, and otherwise outline the proposed size where the bar would appear. - func previewBarHeight(_ height: Double) { - if barController?.isPresented == true { - barHeightGhost.hide() - updateConfiguredBarHeight() - } else { - barHeightGhost.show(height: height) - } - } - func beginBarResize(at screenPoint: NSPoint) { barController?.beginBarResize(at: screenPoint) } @@ -331,8 +315,6 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { if barController == nil || barController?.window == nil { barController = BarWindowController() } - // The real bar supersedes any height outline still lingering. - barHeightGhost.hide() barController?.show() startKeyMonitor() } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index a2e77cc..27484a3 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -138,7 +138,7 @@ private struct GeneralSettings: View { 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) @@ -251,9 +251,6 @@ private struct GeneralSettings: View { } } .formStyle(.grouped) - .onChange(of: settings.barHeight) { _, height in - AppController.shared.previewBarHeight(height) - } #if !MAS .onAppear { accessibilityGranted = AXIsProcessTrusted() } .onReceive(poll) { _ in diff --git a/Sources/Pesty/UI/BarHeightGhostController.swift b/Sources/Pesty/UI/BarHeightGhostController.swift deleted file mode 100644 index 5d3e4e7..0000000 --- a/Sources/Pesty/UI/BarHeightGhostController.swift +++ /dev/null @@ -1,95 +0,0 @@ -import AppKit -import SwiftUI - -/// A translucent stand-in for the Paste Bar, shown while the height slider in -/// Settings moves. Clicking into Settings dismisses the bar, so a number of -/// pixels is otherwise the only feedback available for a size that is only -/// meaningful on screen. -@MainActor -final class BarHeightGhostController { - private static let lingerAfterChange: TimeInterval = 1.1 - - private var panel: NSPanel? - private var hideWork: DispatchWorkItem? - - func show(height: Double) { - guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) - ?? NSScreen.main ?? NSScreen.screens.first else { return } - - let panel = panel ?? makePanel() - self.panel = panel - - // Sized by the same rules the real bar uses, so the outline cannot - // promise a height the display would not actually give. - let frame = BarResizeGeometry.panelFrame(for: height, in: screen.visibleFrame) - panel.setFrame(frame, display: true) - (panel.contentView as? NSHostingView)?.rootView = - BarHeightGhostView(height: frame.height) - // orderFrontRegardless keeps Settings key: this is a readout, and - // stealing focus mid-drag would end the drag. - panel.orderFrontRegardless() - - hideWork?.cancel() - let work = DispatchWorkItem { [weak self] in self?.hide() } - hideWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + Self.lingerAfterChange, execute: work) - } - - func hide() { - hideWork?.cancel() - hideWork = nil - panel?.orderOut(nil) - } - - private func makePanel() -> NSPanel { - let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false) - panel.isFloatingPanel = true - panel.level = .modalPanel - panel.backgroundColor = .clear - panel.isOpaque = false - panel.hasShadow = false - panel.hidesOnDeactivate = false - panel.isMovable = false - // Purely a readout: it must never swallow a click meant for whatever - // it happens to cover. - panel.ignoresMouseEvents = true - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] - panel.contentView = NSHostingView(rootView: BarHeightGhostView(height: 0)) - return panel - } -} - -private struct BarHeightGhostView: View { - let height: CGFloat - - private var shape: RoundedCorners { - RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight]) - } - - var body: some View { - ZStack { - // The real bar, faded: judging a height is much easier against - // actual cards than against an empty rectangle. - BarView(searchBridge: BarSearchFieldBridge()) - .allowsHitTesting(false) - .opacity(0.55) - shape.stroke(Theme.selection, style: StrokeStyle(lineWidth: 2, dash: [9, 7])) - VStack(spacing: 3) { - Text("\(Int(height)) px") - .font(.system(size: 30, weight: .semibold, design: .rounded)) - Text("Paste Bar height") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.white.opacity(0.75)) - } - .foregroundStyle(.white) - .padding(.horizontal, 22) - .padding(.vertical, 12) - .background(Color.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.45), radius: 10, y: 2) - } - .ignoresSafeArea() - } -} diff --git a/Sources/Pesty/UI/BarResizeHandle.swift b/Sources/Pesty/UI/BarResizeHandle.swift index 89a5ce2..b05bb87 100644 --- a/Sources/Pesty/UI/BarResizeHandle.swift +++ b/Sources/Pesty/UI/BarResizeHandle.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon.HIToolbox import SwiftUI /// Lets the Paste Bar's local key monitor leave Escape and drag-time keys to @@ -149,7 +150,7 @@ private final class BarResizeHandleView: NSView, BarResizeHandleResponder { } override func keyDown(with event: NSEvent) { - guard isTrackingDrag, event.keyCode == 53 else { + guard isTrackingDrag, Int(event.keyCode) == kVK_Escape else { super.keyDown(with: event) return } @@ -200,8 +201,11 @@ private final class BarResizeHandleView: NSView, BarResizeHandleResponder { NSCursor.pop() ownsResizeCursor = false } - if window?.firstResponder === self { - window?.makeFirstResponder(previousFirstResponder) + // 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 } diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 8ac03ae..69eaa06 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -292,15 +292,6 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { Settings.shared.barHeight = Double(frame.height) } - func applyConfiguredBarHeight() { - guard phase == .shown, resizeSession == nil, - let visibleFrame = targetVisibleFrame else { return } - applyResizeFrame(BarResizeGeometry.panelFrame( - for: Settings.shared.barHeight, - in: visibleFrame - )) - } - private func applyResizeFrame(_ frame: NSRect) { guard let panel = window, let content = panel.contentView, frame.width > 0, frame.height > 0 else { return } From 276344339d8f5354e0ab15368efa7ae1399b9be1 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:28:28 +0300 Subject: [PATCH 22/23] Undo a deletion for five minutes with Command-Z A deleted clip, or every clip of a bulk delete together, can be put back with Command-Z for five minutes, at the position it came from: after its former predecessor if that clip is still there, else before its successor, else at its old index. An Undo button sits in the bar's top right while anything is restorable, the bulk-delete alert says so instead of "There is no undo", and a clip that came back by other means in the meantime, copied again or synced in, is left alone rather than doubled. The stack lives in memory only. Deleted content is never written back to store.json, there are no tombstones, and the iCloud Drive merge, the CloudKit sync and remote applies are untouched. Image files of restorable clips stay on disk and are removed when the window closes, on Clear History, when a Pinboard is deleted, or at quit, the four moments the stack empties. A "Delete permanently" setting, off by default, skips the stack, and holding Option while pressing Backspace, Delete or Command-Backspace skips it for that one deletion; the Option state comes from the key event itself, and the context menu has no hidden modifier. Designed after #88 by @alvst, whose placement bookkeeping, button and alert copy this keeps. --- Sources/Pesty/AppController.swift | 28 ++++-- Sources/Pesty/Settings/Settings.swift | 9 ++ Sources/Pesty/Settings/SettingsView.swift | 4 + Sources/Pesty/Store/ClipUndoStack.swift | 73 ++++++++++++++ Sources/Pesty/Store/ClipboardStore.swift | 111 ++++++++++++++++++++-- Sources/Pesty/UI/BarView.swift | 18 ++++ Tests/PestyTests/ClipUndoStackTests.swift | 64 +++++++++++++ 7 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 Sources/Pesty/Store/ClipUndoStack.swift create mode 100644 Tests/PestyTests/ClipUndoStackTests.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 4c5754c..555d004 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -84,6 +84,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { } func applicationWillTerminate(_ notification: Notification) { + store.forgetDeletions() store.saveNow() } @@ -478,28 +479,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) { @@ -620,7 +627,7 @@ 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 @@ -640,16 +647,19 @@ 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: diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 6c9689a..7b9a327 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -208,6 +208,7 @@ final class Settings { static let playSound = "playSound" static let playSoundOnCopy = "playSoundOnCopy" static let ignoreConcealed = "ignoreConcealed" + static let deletePermanently = "deletePermanently" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" static let showBarResizeHandle = "showBarResizeHandle" @@ -297,6 +298,12 @@ final class Settings { 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. @@ -369,6 +376,7 @@ final class Settings { // shouldn't gain a new audible behavior from an update. Keys.playSoundOnCopy: false, Keys.ignoreConcealed: true, + Keys.deletePermanently: false, Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: BarResizeGeometry.defaultHeight, Keys.showBarResizeHandle: false, @@ -394,6 +402,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) playSoundOnCopy = d.bool(forKey: Keys.playSoundOnCopy) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) + deletePermanently = d.bool(forKey: Keys.deletePermanently) ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) .filter { !$0.isEmpty } let storedBarHeight = d.double(forKey: Keys.barHeight) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 27484a3..6b7ad8d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,6 +245,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() } 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 35c6b5a..0bb0392 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -27,6 +27,11 @@ 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() } } } @@ -226,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 { @@ -265,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) @@ -289,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() @@ -544,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/BarView.swift b/Sources/Pesty/UI/BarView.swift index 0bab5a2..f8929ea 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -68,6 +68,9 @@ struct BarView: View { if store.multiSelectedIDs.count > 1 { bulkDeleteButton } + if store.hasUndoableDeletion { + undoButton + } moreMenu } .padding(.horizontal, 18) @@ -151,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: { 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) + } +} From ecbbd47c8c438b17ed1f17591bfc9b09b3a18502 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Thu, 8 Oct 2026 05:30:36 +0300 Subject: [PATCH 23/23] Add a promote-on-paste option and a notice when direct paste is blocked Pasting can move the clip to the front of history the way copying it from the bar does, behind a "Promote pasted clips to the top of history" toggle that is off by default: history has always been ordered by when a clip was copied, and the quick-paste numbers would shift on every paste. The promotion uses the copy path's guards, so it happens only when the pasteboard actually changed and never puts a Pinboard copy's UUID into history. In the direct-download build a paste that the missing Accessibility grant turns into a plain copy used to end in silence, which reads as a broken paste. The toast now says "Copied. Grant Accessibility in System Settings to paste directly." once per launch, without a modal and without taking focus, and sizes itself to its text. The App Store build never reaches that path and compiles none of it. Both come from #92 by @alvst, whose Command-Delete change and modal alert are left out. --- Sources/Pesty/AppController.swift | 20 +++++++++++++++++ Sources/Pesty/Monitor/PasteService.swift | 6 +++++- Sources/Pesty/Settings/Settings.swift | 11 ++++++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/CopyToastController.swift | 25 ++++++++++++++++------ 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 555d004..cb6a4a7 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -17,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? @@ -380,8 +383,25 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { func pasteItem(_ item: ClipItem, format: PasteFormat = .original) { let target = pasteTarget hideBar() + 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 diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 765e02d..1cce40e 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -111,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 7b9a327..3adf605 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -207,6 +207,7 @@ final class Settings { 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" @@ -294,6 +295,14 @@ final class Settings { 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) } } @@ -375,6 +384,7 @@ final class Settings { // 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: [], @@ -401,6 +411,7 @@ final class Settings { 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) ?? []) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 6b7ad8d..001cd03 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -132,6 +132,7 @@ 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) diff --git a/Sources/Pesty/UI/CopyToastController.swift b/Sources/Pesty/UI/CopyToastController.swift index 147a86a..1e5601f 100644 --- a/Sources/Pesty/UI/CopyToastController.swift +++ b/Sources/Pesty/UI/CopyToastController.swift @@ -4,9 +4,12 @@ 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], @@ -18,16 +21,22 @@ final class CopyToastController { panel.level = .floating panel.ignoresMouseEvents = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.contentView = NSHostingView(rootView: CopyToastView()) + panel.contentView = host } - func show() { + /// 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 @@ -40,7 +49,7 @@ final class CopyToastController { let dismiss = DispatchWorkItem { [weak self] in self?.dismiss() } dismissWorkItem = dismiss - DispatchQueue.main.asyncAfter(deadline: .now() + 1.25, execute: dismiss) + DispatchQueue.main.asyncAfter(deadline: .now() + linger, execute: dismiss) } private func dismiss() { @@ -54,17 +63,21 @@ final class CopyToastController { } private struct CopyToastView: View { + let message: String + let symbol: String + var body: some View { HStack(spacing: 9) { - Image(systemName: "checkmark.circle.fill") + Image(systemName: symbol) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Theme.selection) - Text("Copied to Clipboard") + Text(message) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) + .lineLimit(1) } .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 14) .background(.regularMaterial, in: Capsule(style: .continuous)) .overlay { Capsule(style: .continuous)