From 622e8f679c4a5944509892f9ca58aba005532ba4 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:19:23 -0700 Subject: [PATCH 01/16] Resize visible bar when height changes --- Sources/Pesty/AppController.swift | 4 ++++ Sources/Pesty/Settings/Settings.swift | 1 + Sources/Pesty/UI/BarWindowController.swift | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..28be28e 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -150,6 +150,10 @@ final class AppController: NSObject, NSApplicationDelegate { barController?.hide() } + func resizeVisibleBar(to height: Double) { + barController?.resize(to: CGFloat(height)) + } + func pasteSelected() { guard let item = store.selectedItem else { return } hideBar() diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..1eb7082 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -65,6 +65,7 @@ final class Settings { let clamped = min(720, max(240, barHeight)) if clamped != barHeight { barHeight = clamped; return } d.set(barHeight, forKey: Keys.barHeight) + AppController.shared.resizeVisibleBar(to: barHeight) } } diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..5b0969b 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -68,6 +68,18 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { }) } + /// Updates the open panel immediately so dragging the resize handle feels + /// attached to the bar instead of merely changing a future preference. + func resize(to height: CGFloat) { + guard let panel = window, panel.isVisible else { return } + guard let screen = panel.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(panel.frame) }) + ?? NSScreen.main else { return } + let visible = screen.visibleFrame + panel.setFrame(NSRect(x: visible.minX, y: visible.minY, + width: visible.width, height: height), display: true) + } + func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } AppController.shared.hideBar() From ae6b9c247e403fde91cd8e948b907b5c96153c1a Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:21:24 -0700 Subject: [PATCH 02/16] Hide sync control when sync is disabled --- Sources/Pesty/UI/BarView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..7440aec 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -21,7 +21,9 @@ struct BarView: View { private var topBar: some View { HStack(spacing: 14) { - syncButton + if settings.iCloudSync { + syncButton + } searchIndicator PinboardTabs() .layoutPriority(1) From 043e1ada1dfc40bff2d6c5f70a296911f4863764 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:42:21 -0700 Subject: [PATCH 03/16] Use sidebar navigation in Settings --- Sources/Pesty/AppController.swift | 2 +- Sources/Pesty/Settings/SettingsView.swift | 80 +++++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..319c8ab 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -178,7 +178,7 @@ final class AppController: NSObject, NSApplicationDelegate { let win = NSWindow(contentViewController: host) win.title = "Pesty Settings" win.styleMask = [.titled, .closable, .miniaturizable] - win.setContentSize(NSSize(width: 520, height: 560)) + win.setContentSize(NSSize(width: 680, height: 560)) win.center() win.isReleasedWhenClosed = false settingsWindow = win diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..5ec5f9e 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -2,14 +2,82 @@ import SwiftUI import AppKit struct SettingsView: View { + private enum Section: String, CaseIterable, Identifiable { + case general + case about + + var id: Self { self } + + var title: String { + switch self { + case .general: "General" + case .about: "About" + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .about: "info.circle" + } + } + } + + @State private var section: Section = .general + var body: some View { - TabView { - GeneralSettings() - .tabItem { Label("General", systemImage: "gearshape") } - AboutView() - .tabItem { Label("About", systemImage: "info.circle") } + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(nsImage: NSApp.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 30, height: 30) + Text("Pesty") + .font(.headline) + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 14) + + ForEach(Section.allCases) { item in + Button { + section = item + } label: { + Label(item.title, systemImage: item.symbol) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(section == item ? .primary : .secondary) + .background { + if section == item { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary) + } + } + } + + Spacer() + } + .padding(.horizontal, 8) + .frame(width: 180) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + Group { + switch section { + case .general: + GeneralSettings() + case .about: + AboutView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 520, height: 560) + .frame(width: 680, height: 560) } } From c84455460675617b8db40253241d2559accb2f83 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:48:54 -0700 Subject: [PATCH 04/16] Add Quick Look previews for clips --- Sources/Pesty/AppController.swift | 12 ++- Sources/Pesty/UI/BarWindowController.swift | 8 +- Sources/Pesty/Util/QuickLookService.swift | 112 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 Sources/Pesty/Util/QuickLookService.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..afe6740 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -211,6 +211,9 @@ final class AppController: NSObject, NSApplicationDelegate { } switch code { + case kVK_Space where store.searchText.isEmpty: + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } else { hideBar() } @@ -218,9 +221,9 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_Return, kVK_ANSI_KeypadEnter: pasteSelected(); return nil case kVK_LeftArrow, kVK_UpArrow: - store.moveSelection(by: -1); return nil + moveBarSelection(by: -1); return nil case kVK_RightArrow, kVK_DownArrow: - store.moveSelection(by: 1); return nil + moveBarSelection(by: 1); return nil case kVK_Delete: if cmd, let sel = store.selectedItem { store.delete(sel); return nil } if !store.searchText.isEmpty { @@ -244,6 +247,11 @@ final class AppController: NSObject, NSApplicationDelegate { } return event } + + private func moveBarSelection(by delta: Int) { + store.moveSelection(by: delta) + QuickLookService.shared.updateSelection(selectedID: store.selectedID) + } } extension Bundle { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..3661525 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -70,6 +70,12 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } - AppController.shared.hideBar() + // Quick Look becomes key immediately after the strip hands it a preview. + // Defer until that transition is visible before deciding whether focus + // actually left Pesty. + DispatchQueue.main.async { + guard !QuickLookService.shared.isVisible else { return } + AppController.shared.hideBar() + } } } diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift new file mode 100644 index 0000000..f6f286c --- /dev/null +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -0,0 +1,112 @@ +import AppKit +@preconcurrency import QuickLookUI + +@MainActor +final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = QuickLookService() + + private var previewItems: [PreviewItem] = [] + private var startIndexByClipID: [UUID: Int] = [:] + private let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("Pesty-QuickLook", isDirectory: true) + + private override init() {} + + var isVisible: Bool { QLPreviewPanel.shared()?.isVisible ?? false } + + func toggle(items: [ClipItem], selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared() else { return } + if panel.isVisible { + panel.orderOut(nil) + return + } + + prepareTemporaryDirectory() + var selectedIndex = 0 + var newItems: [PreviewItem] = [] + var newStartIndexes: [UUID: Int] = [:] + for clip in items { + let startIndex = newItems.count + newItems.append(contentsOf: previewItems(for: clip)) + if startIndex < newItems.count { newStartIndexes[clip.id] = startIndex } + if clip.id == selectedID, startIndex < newItems.count { selectedIndex = startIndex } + } + guard !newItems.isEmpty else { return } + + previewItems = newItems + startIndexByClipID = newStartIndexes + panel.dataSource = self + panel.reloadData() + panel.currentPreviewItemIndex = selectedIndex + panel.makeKeyAndOrderFront(nil) + } + + func updateSelection(selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared(), panel.isVisible, + let selectedID, let index = startIndexByClipID[selectedID] else { return } + panel.currentPreviewItemIndex = index + } + + func numberOfPreviewItems(in panel: QLPreviewPanel) -> Int { previewItems.count } + + func previewPanel(_ panel: QLPreviewPanel, previewItemAt index: Int) -> QLPreviewItem { + previewItems[index] + } + + private func previewItems(for clip: ClipItem) -> [PreviewItem] { + switch clip.type { + case .file: + let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: $0.lastPathComponent) } } + case .image: + if let url = ClipboardStore.shared.imageURL(for: clip) { + return [PreviewItem(url: url, title: clip.displayTitle)] + } + case .richText: + if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { + return [PreviewItem(url: url, title: clip.displayTitle)] + } + case .color: + let hex = clip.colorHex ?? "#000000" + let html = "\(hex)" + if let url = write(Data(html.utf8), named: "Color \(hex)", extension: "html") { + return [PreviewItem(url: url, title: hex)] + } + case .text, .link: + break + } + + let text = clip.text ?? clip.displayTitle + guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } + return [PreviewItem(url: url, title: clip.displayTitle)] + } + + private func prepareTemporaryDirectory() { + try? FileManager.default.removeItem(at: temporaryDirectory) + try? FileManager.default.createDirectory(at: temporaryDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + private func write(_ data: Data, named title: String, extension fileExtension: String) -> URL? { + let safeTitle = title.replacingOccurrences(of: "/", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseName = safeTitle.isEmpty ? "Clip" : String(safeTitle.prefix(80)) + let filename = "\(baseName)-\(UUID().uuidString).\(fileExtension)" + let url = temporaryDirectory.appendingPathComponent(filename) + do { + try data.write(to: url, options: .atomic) + return url + } catch { return nil } + } +} + +private final class PreviewItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String) { + previewItemURL = url + previewItemTitle = title + } +} From 17929d7d0650a1d6e166e7336e5bbc76be155bef Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 20:45:37 -0700 Subject: [PATCH 05/16] Add optional Pesty bar resize handle --- Sources/Pesty/Settings/Settings.swift | 7 +++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/BarView.swift | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 1eb7082..d52e860 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -19,6 +19,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -69,6 +70,10 @@ final class Settings { } } + var showBarResizeHandle: Bool { + didSet { guard isLoaded else { return }; d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -87,6 +92,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.showBarResizeHandle: false, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -98,6 +104,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..b61a7c0 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -38,6 +38,7 @@ private struct GeneralSettings: View { Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) Toggle("Launch at login", isOn: $settings.launchAtLogin) + Toggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..d338362 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,6 +3,7 @@ import SwiftUI struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + @State private var resizeStartHeight: Double? var body: some View { ZStack { @@ -11,6 +12,7 @@ struct BarView: View { } .overlay(alignment: .top) { VStack(spacing: 0) { + if settings.showBarResizeHandle { resizeHandle } topBar strip } @@ -19,6 +21,27 @@ struct BarView: View { .ignoresSafeArea() } + private var resizeHandle: some View { + HStack { + Capsule(style: .continuous) + .fill(Theme.textTertiary.opacity(0.7)) + .frame(width: 42, height: 4) + } + .frame(maxWidth: .infinity) + .frame(height: 14) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if resizeStartHeight == nil { resizeStartHeight = settings.barHeight } + guard let start = resizeStartHeight else { return } + settings.barHeight = min(720, max(300, start - value.translation.height)) + } + .onEnded { _ in resizeStartHeight = nil } + ) + .help("Drag to resize the Pesty bar") + } + private var topBar: some View { HStack(spacing: 14) { syncButton From 30cfff47e0f10d92c3a7314bf5cb62b8aeb9bdd9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:58:13 -0700 Subject: [PATCH 06/16] Render text and links richly in Quick Look --- Sources/Pesty/Util/QuickLookService.swift | 57 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift index f6f286c..0479013 100644 --- a/Sources/Pesty/Util/QuickLookService.swift +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -57,14 +57,14 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource switch clip.type { case .file: let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) - if !files.isEmpty { return files.map { PreviewItem(url: $0, title: $0.lastPathComponent) } } + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.type.label) } } case .image: if let url = ClipboardStore.shared.imageURL(for: clip) { - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } case .richText: if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } case .color: let hex = clip.colorHex ?? "#000000" @@ -72,13 +72,21 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource if let url = write(Data(html.utf8), named: "Color \(hex)", extension: "html") { return [PreviewItem(url: url, title: hex)] } - case .text, .link: - break + case .text: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text).utf8), named: "Text", extension: "html") { + return [PreviewItem(url: url, title: clip.type.label)] + } + case .link: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text, isLink: true).utf8), named: "Link", extension: "html") { + return [PreviewItem(url: url, title: clip.type.label)] + } } let text = clip.text ?? clip.displayTitle guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } - return [PreviewItem(url: url, title: clip.displayTitle)] + return [PreviewItem(url: url, title: clip.type.label)] } private func prepareTemporaryDirectory() { @@ -99,6 +107,43 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource return url } catch { return nil } } + + /// Use a local, self-contained document so Quick Look can present text and + /// links more readably without loading remote content or running scripts. + private func textPreviewHTML(for text: String, isLink: Bool = false) -> String { + let characterCount = text.count + let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count + let lineCount = max(1, text.split(separator: "\n", omittingEmptySubsequences: false).count) + let escaped = escapeHTML(text) + let content = isLink ? "
\(escaped)
" : "
\(escaped)
" + let words = wordCount == 1 ? "word" : "words" + let lines = lineCount == 1 ? "line" : "lines" + + return """ + + +
\(content)
+
\(characterCount) characters·\(wordCount) \(words)·\(lineCount) \(lines)
+ + """ + } + + private func escapeHTML(_ text: String) -> String { + text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } } private final class PreviewItem: NSObject, QLPreviewItem { From ac624e1057895833ca399c70885542d775403dde Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:11:26 -0700 Subject: [PATCH 07/16] Add configurable clip previews --- Sources/Pesty/AppController.swift | 8 +- Sources/Pesty/Settings/Settings.swift | 28 ++++ Sources/Pesty/Settings/SettingsView.swift | 12 ++ Sources/Pesty/Store/ClipboardStore.swift | 1 + Sources/Pesty/UI/BarView.swift | 22 ++- Sources/Pesty/UI/ClipPreviewViews.swift | 193 ++++++++++++++++++++++ Sources/Pesty/Util/LinkPreviewStore.swift | 77 +++++++++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 Sources/Pesty/UI/ClipPreviewViews.swift create mode 100644 Sources/Pesty/Util/LinkPreviewStore.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index afe6740..e8135e5 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -137,6 +137,7 @@ final class AppController: NSObject, NSApplicationDelegate { store.searchText = "" store.source = .history store.selectFirst() + store.inlinePreviewVisible = false if barController == nil { barController = BarWindowController() @@ -147,6 +148,7 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() + store.inlinePreviewVisible = false barController?.hide() } @@ -212,7 +214,11 @@ final class AppController: NSObject, NSApplicationDelegate { switch code { case kVK_Space where store.searchText.isEmpty: - QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + if Settings.shared.clipPreviewStyle == .nativeQuickLook { + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + } else if store.selectedItem != nil { + store.inlinePreviewVisible.toggle() + } return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..2ef2586 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,27 @@ import AppKit import Carbon.HIToolbox import Observation +enum ClipPreviewStyle: Int, CaseIterable, Identifiable { + case nativeQuickLook + case inlinePesty + + var id: Int { rawValue } + + var title: String { + switch self { + case .nativeQuickLook: "Native Quick Look" + case .inlinePesty: "Inline Pesty preview" + } + } + + var detail: String { + switch self { + case .nativeQuickLook: "Open a macOS Quick Look panel with Space." + case .inlinePesty: "Show a rich preview with link titles and favicons inside Pesty." + } + } +} + @Observable @MainActor final class Settings { @@ -19,6 +40,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let clipPreviewStyle = "clipPreviewStyle" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -68,6 +90,10 @@ final class Settings { } } + var clipPreviewStyle: ClipPreviewStyle { + didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -86,6 +112,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -97,6 +124,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..65226f7 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -48,6 +48,18 @@ private struct GeneralSettings: View { #endif } + Section("Clip previews") { + Picker("Preview style", selection: $settings.clipPreviewStyle) { + ForEach(ClipPreviewStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sync") { Toggle("Sync clipboard via iCloud Drive", isOn: Binding( get: { settings.iCloudSync }, diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..b02a403 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -17,6 +17,7 @@ final class ClipboardStore { var source: BarSource = .history var searchText: String = "" var selectedID: UUID? + var inlinePreviewVisible = false var historyLimit: Int { get { Settings.shared.historyLimit } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..f2c13c7 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -12,7 +12,15 @@ struct BarView: View { .overlay(alignment: .top) { VStack(spacing: 0) { topBar - strip + HStack(spacing: 0) { + if settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible, + let item = store.selectedItem { + SelectedClipPreviewView(item: item) + Divider() + } + strip + } } } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) @@ -26,12 +34,24 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if settings.clipPreviewStyle == .inlinePesty { previewButton } moreMenu } .padding(.horizontal, 18) .frame(height: 56) } + private var previewButton: some View { + Button { store.inlinePreviewVisible.toggle() } label: { + Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help(store.inlinePreviewVisible ? "Hide clip preview" : "Show clip preview") + } + private var syncButton: some View { Button { AppController.shared.toggleICloudSync() diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift new file mode 100644 index 0000000..438c16f --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -0,0 +1,193 @@ +import AppKit +import SwiftUI + +struct RichTextContent: View { + let rtfData: Data? + let fallback: String + var font: Font = .system(size: 13) + var lineLimit: Int? = nil + + var body: some View { + Group { + if let richText { + Text(richText) + } else { + Text(fallback) + } + } + .font(font) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + } + + private var richText: AttributedString? { + guard let rtfData, + let value = try? NSAttributedString(data: rtfData, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil) else { return nil } + return AttributedString(value) + } +} + +struct LinkPreviewContent: View { + let text: String + let compact: Bool + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + HStack(spacing: compact ? 8 : 12) { + icon + VStack(alignment: .leading, spacing: compact ? 2 : 5) { + Text(preview?.title ?? host) + .font(.system(size: compact ? 12 : 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(compact ? 2 : 3) + Text(host) + .font(.system(size: compact ? 10 : 12)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .onAppear { previews.load(for: url) } + } + + @ViewBuilder + private var icon: some View { + if let image = preview?.icon { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .clipShape(RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .overlay { + Image(systemName: "link") + .font(.system(size: compact ? 12 : 17, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + } +} + +struct SelectedClipPreviewView: View { + let item: ClipItem + private var store: ClipboardStore { ClipboardStore.shared } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: item.type.symbol) + .foregroundStyle(item.type.accent) + Text(item.type.label) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Theme.textSecondary) + Spacer() + Text(item.createdAt.clipRelativeLong) + .font(.system(size: 11)) + .foregroundStyle(Theme.textTertiary) + } + previewContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + Text(item.displayTitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(2) + } + .padding(16) + .frame(width: 340) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.58)) + } + + @ViewBuilder + private var previewContent: some View { + switch item.type { + case .image: + if let image = store.loadImage(for: item) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { missingPreview("photo") } + case .richText: + ScrollView { + RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", font: .system(size: 15)) + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + case .link: + VStack(spacing: 14) { + LinkPreviewContent(text: item.text ?? item.displayTitle, compact: false) + Text(item.text ?? "") + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + .lineLimit(3) + Spacer() + } + case .file: + if let image = filePreviewImage { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 46, weight: .light)) + .foregroundStyle(item.type.accent) + Text(item.displayTitle) + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.textPrimary) + Spacer() + } + .frame(maxWidth: .infinity) + } + case .color: + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(hex: item.colorHex ?? "#000") ?? .black) + .overlay { + Text(item.colorHex ?? "") + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .shadow(radius: 2) + } + case .text: + ScrollView { + Text(item.text ?? "") + .font(.system(size: 15)) + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + } + + private func missingPreview(_ symbol: String) -> some View { + Image(systemName: symbol) + .font(.system(size: 38, weight: .light)) + .foregroundStyle(Theme.textTertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var filePreviewImage: NSImage? { + guard item.fileURLs.count == 1, + let value = item.fileURLs.first, + let url = URL(string: value), + url.isFileURL else { return nil } + return NSImage(contentsOf: url) + } +} diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift new file mode 100644 index 0000000..4702bec --- /dev/null +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -0,0 +1,77 @@ +import AppKit +import Foundation +import Observation + +struct LinkPreview { + var title: String? + var icon: NSImage? +} + +@Observable +@MainActor +final class LinkPreviewStore { + static let shared = LinkPreviewStore() + + private var previews: [String: LinkPreview] = [:] + private var loadingHosts: Set = [] + + private init() {} + + func preview(for url: URL?) -> LinkPreview? { + guard let host = url?.host?.lowercased() else { return nil } + return previews[host] + } + + func load(for url: URL?) { + guard let url, + let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), + let host = url.host?.lowercased(), + !loadingHosts.contains(host) else { return } + loadingHosts.insert(host) + previews[host] = previews[host] ?? LinkPreview() + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") + URLSession.shared.dataTask(with: request) { data, _, _ in + let title = data.flatMap(Self.pageTitle(from:)) + DispatchQueue.main.async { + self.update(host: host, title: title, icon: nil, finished: false) + } + }.resume() + + var faviconURL = URLComponents() + faviconURL.scheme = scheme + faviconURL.host = host + faviconURL.path = "/favicon.ico" + guard let iconURL = faviconURL.url else { + loadingHosts.remove(host) + return + } + URLSession.shared.dataTask(with: iconURL) { data, _, _ in + let icon = data.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: icon, finished: true) + } + }.resume() + } + + private func update(host: String, title: String?, icon: NSImage?, finished: Bool) { + var preview = previews[host] ?? LinkPreview() + if let title, !title.isEmpty { preview.title = title } + if let icon { preview.icon = icon } + previews[host] = preview + if finished { loadingHosts.remove(host) } + } + + nonisolated private static func pageTitle(from data: Data) -> String? { + guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), + let expression = try? NSRegularExpression(pattern: "]*>(.*?)", + options: [.caseInsensitive, .dotMatchesLineSeparators]), + let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let range = Range(match.range(at: 1), in: html) else { return nil } + return html[range] + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} From 167378f55dca5adf01bf8795562fcd01eae25e25 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:52:51 -0700 Subject: [PATCH 08/16] Configure quick paste modifiers --- Sources/Pesty/AppController.swift | 26 +++++++--- Sources/Pesty/Monitor/PasteService.swift | 14 ++++-- Sources/Pesty/Settings/Settings.swift | 61 +++++++++++++++++++++++ Sources/Pesty/Settings/SettingsView.swift | 26 ++++++++++ Sources/Pesty/UI/ClipCardView.swift | 5 +- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..349692b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -150,15 +150,15 @@ final class AppController: NSObject, NSApplicationDelegate { barController?.hide() } - func pasteSelected() { + func pasteSelected(asPlainText: Bool = false) { guard let item = store.selectedItem else { return } hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + PasteService.paste(item, into: previousApp, monitor: monitor, asPlainText: asPlainText) } - func pasteItem(_ item: ClipItem) { + func pasteItem(_ item: ClipItem, asPlainText: Bool = false) { hideBar() - PasteService.paste(item, into: previousApp, monitor: monitor) + PasteService.paste(item, into: previousApp, monitor: monitor, asPlainText: asPlainText) } func copyItem(_ item: ClipItem) { @@ -204,9 +204,13 @@ final class AppController: NSObject, NSApplicationDelegate { let ctrl = flags.contains(.control) let opt = flags.contains(.option) - if cmd, let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n) { + if includes(Settings.shared.quickPasteModifier, in: flags), + let chars = event.charactersIgnoringModifiers, + let n = Int(chars), (1...9).contains(n) { let items = store.visibleItems - if n <= items.count { pasteItem(items[n - 1]) } + if n <= items.count { + pasteItem(items[n - 1], asPlainText: includes(Settings.shared.plainTextModifier, in: flags)) + } return nil } @@ -244,6 +248,16 @@ final class AppController: NSObject, NSApplicationDelegate { } return event } + + private func includes(_ carbonModifier: Int, in flags: NSEvent.ModifierFlags) -> Bool { + switch carbonModifier { + case cmdKey: return flags.contains(.command) + case optionKey: return flags.contains(.option) + case controlKey: return flags.contains(.control) + case shiftKey: return flags.contains(.shift) + default: return false + } + } } extension Bundle { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..42a45f9 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -5,7 +5,14 @@ import Carbon.HIToolbox enum PasteService { @discardableResult - static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int { + static func copy(_ item: ClipItem, + to pasteboard: NSPasteboard = .general, + asPlainText: Bool = false) -> Int { + if asPlainText, let text = item.text ?? item.colorHex { + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + return pasteboard.changeCount + } if item.type == .image { guard let img = ClipboardStore.shared.loadImage(for: item) else { return pasteboard.changeCount @@ -38,8 +45,9 @@ enum PasteService { static func paste(_ item: ClipItem, into targetApp: NSRunningApplication?, - monitor: ClipboardMonitor) { - let change = copy(item) + monitor: ClipboardMonitor, + asPlainText: Bool = false) { + let change = copy(item, asPlainText: asPlainText) monitor.suppressUntilChangeCount = change if Settings.shared.playSound { NSSound(named: "Pop")?.play() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..7ac2807 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,49 @@ import AppKit import Carbon.HIToolbox import Observation +enum ShortcutModifier: CaseIterable, Identifiable { + case command + case option + case control + case shift + + var id: Int { carbonValue } + + var carbonValue: Int { + switch self { + case .command: return cmdKey + case .option: return optionKey + case .control: return controlKey + case .shift: return shiftKey + } + } + + var title: String { + switch self { + case .command: return "Command" + case .option: return "Option" + case .control: return "Control" + case .shift: return "Shift" + } + } + + var symbol: String { + switch self { + case .command: return "⌘" + case .option: return "⌥" + case .control: return "⌃" + case .shift: return "⇧" + } + } + + init?(carbonValue: Int) { + guard let modifier = Self.allCases.first(where: { $0.carbonValue == carbonValue }) else { + return nil + } + self = modifier + } +} + @Observable @MainActor final class Settings { @@ -14,6 +57,8 @@ final class Settings { static let historyLimit = "historyLimit" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" + static let quickPasteModifier = "quickPasteModifier" + static let plainTextModifier = "plainTextModifier" static let launchAtLogin = "launchAtLogin" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" @@ -42,6 +87,14 @@ final class Settings { d.set(hotkeyModifiers, forKey: Keys.hotkeyModifiers); HotKeyCenter.shared.reload() } } + var quickPasteModifier: Int { + didSet { guard isLoaded else { return }; d.set(quickPasteModifier, forKey: Keys.quickPasteModifier) } + } + + var plainTextModifier: Int { + didSet { guard isLoaded else { return }; d.set(plainTextModifier, forKey: Keys.plainTextModifier) } + } + var launchAtLogin: Bool { didSet { guard isLoaded else { return } d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } @@ -81,6 +134,8 @@ final class Settings { Keys.historyLimit: 500, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, + Keys.quickPasteModifier: ShortcutModifier.command.carbonValue, + Keys.plainTextModifier: ShortcutModifier.shift.carbonValue, Keys.launchAtLogin: false, Keys.pasteDirectly: true, Keys.playSound: false, @@ -92,6 +147,8 @@ final class Settings { historyLimit = d.integer(forKey: Keys.historyLimit) hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) + quickPasteModifier = d.integer(forKey: Keys.quickPasteModifier) + plainTextModifier = d.integer(forKey: Keys.plainTextModifier) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) @@ -105,4 +162,8 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + var quickPasteModifierDisplay: String { + ShortcutModifier(carbonValue: quickPasteModifier)?.symbol ?? "⌘" + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..09ad14b 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -31,6 +31,21 @@ private struct GeneralSettings: View { } } + Section("Quick Paste") { + LabeledContent("Paste items 1–9") { + HStack(spacing: 6) { + modifierPicker(selection: $settings.quickPasteModifier) + Text("+ 1…9").foregroundStyle(.secondary) + } + } + LabeledContent("Paste as plain text") { + modifierPicker(selection: $settings.plainTextModifier) + } + Text("Hold the plain-text modifier while using Quick Paste to remove formatting. For example, ⌘⇧1 pastes the first item as plain text with the default shortcuts.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) @@ -111,6 +126,17 @@ private struct GeneralSettings: View { } } #endif + + private func modifierPicker(selection: Binding) -> some View { + Picker("", selection: selection) { + ForEach(ShortcutModifier.allCases) { modifier in + Text("\(modifier.symbol) \(modifier.title)").tag(modifier.carbonValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(minWidth: 118) + } } private struct AboutView: View { diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..9857027 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -7,6 +7,7 @@ struct ClipCardView: View { @State private var hovering = false private var store: ClipboardStore { ClipboardStore.shared } + private var settings: Settings { Settings.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } var body: some View { @@ -143,8 +144,8 @@ struct ClipCardView: View { Spacer(minLength: 4) if index < 9 { HStack(spacing: 3) { - Image(systemName: "line.3.horizontal") - .font(.system(size: 9, weight: .semibold)) + Text(settings.quickPasteModifierDisplay) + .font(.system(size: 11, weight: .semibold)) Text("\(index + 1)") .font(.system(size: 11, weight: .semibold)) } From 545a241919d2f3301a9101a4dfe25a9cc7ad39cb Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:53:52 -0700 Subject: [PATCH 09/16] Add count and time history retention --- Sources/Pesty/AppController.swift | 1 + Sources/Pesty/Settings/Settings.swift | 132 +++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 50 +++++++- Sources/Pesty/Store/ClipboardStore.swift | 33 ++++-- 4 files changed, 201 insertions(+), 15 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..cf63e17 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -136,6 +136,7 @@ final class AppController: NSObject, NSApplicationDelegate { } store.searchText = "" store.source = .history + store.applyHistoryPolicy() store.selectFirst() if barController == nil { diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..a3c9597 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -2,6 +2,112 @@ import AppKit import Carbon.HIToolbox import Observation +enum HistoryRetention: Int, CaseIterable, Identifiable { + case day + case week + case month + case year + case forever + case twoWeeks + case threeWeeks + case twoMonths + case threeMonths + case sixMonths + + // Preserve the original raw values so a saved selection remains valid as + // additional intervals are introduced. + static let allCases: [HistoryRetention] = [ + .day, .week, .twoWeeks, .threeWeeks, .month, + .twoMonths, .threeMonths, .sixMonths, .year, .forever + ] + + var id: Int { rawValue } + + var title: String { + switch self { + case .day: "1 Day" + case .week: "1 Week" + case .twoWeeks: "2 Weeks" + case .threeWeeks: "3 Weeks" + case .month: "1 Month" + case .twoMonths: "2 Months" + case .threeMonths: "3 Months" + case .sixMonths: "6 Months" + case .year: "1 Year" + case .forever: "Forever" + } + } + + var description: String { + switch self { + case .day: "Clips are kept for 24 hours." + case .week: "Clips are kept for 7 days." + case .twoWeeks: "Clips are kept for 2 weeks." + case .threeWeeks: "Clips are kept for 3 weeks." + case .month: "Clips are kept for 1 month." + case .twoMonths: "Clips are kept for 2 months." + case .threeMonths: "Clips are kept for 3 months." + case .sixMonths: "Clips are kept for 6 months." + case .year: "Clips are kept for 1 year." + case .forever: "Clips are kept until you erase them." + } + } + + var cutoffDate: Date? { + let calendar = Calendar.current + switch self { + case .day: return calendar.date(byAdding: .day, value: -1, to: .now) + case .week: return calendar.date(byAdding: .day, value: -7, to: .now) + case .twoWeeks: return calendar.date(byAdding: .day, value: -14, to: .now) + case .threeWeeks: return calendar.date(byAdding: .day, value: -21, to: .now) + case .month: return calendar.date(byAdding: .month, value: -1, to: .now) + case .twoMonths: return calendar.date(byAdding: .month, value: -2, to: .now) + case .threeMonths: return calendar.date(byAdding: .month, value: -3, to: .now) + case .sixMonths: return calendar.date(byAdding: .month, value: -6, to: .now) + case .year: return calendar.date(byAdding: .year, value: -1, to: .now) + case .forever: return nil + } + } + + var sliderIndex: Double { + Double(Self.allCases.firstIndex(of: self) ?? 0) + } + + var shortSliderTitle: String { + switch self { + case .day: "1d" + case .week: "1w" + case .twoWeeks: "2w" + case .threeWeeks: "3w" + case .month: "1m" + case .twoMonths: "2m" + case .threeMonths: "3m" + case .sixMonths: "6m" + case .year: "1y" + case .forever: "∞" + } + } + + init(sliderIndex: Double) { + let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded()))) + self = Self.allCases[index] + } +} + +enum HistoryRetentionMode: Int, CaseIterable, Identifiable { + case itemCount + case timePeriod + + var id: Int { rawValue } + + var title: String { + switch self { + case .itemCount: "Number" + case .timePeriod: "Time" + } + } +} + @Observable @MainActor final class Settings { @@ -12,6 +118,8 @@ final class Settings { enum Keys { static let historyLimit = "historyLimit" + static let historyRetentionMode = "historyRetentionMode" + static let historyRetention = "historyRetention" static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let launchAtLogin = "launchAtLogin" @@ -28,7 +136,25 @@ final class Settings { guard isLoaded else { return } if historyLimit < 20 { historyLimit = 20; return } d.set(historyLimit, forKey: Keys.historyLimit) - ClipboardStore.shared.applyHistoryLimit() + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetentionMode: HistoryRetentionMode { + didSet { + guard isLoaded else { return } + d.set(historyRetentionMode.rawValue, forKey: Keys.historyRetentionMode) + ClipboardStore.shared.applyHistoryPolicy() + } + } + + var historyRetention: HistoryRetention { + didSet { + guard isLoaded else { return } + d.set(historyRetention.rawValue, forKey: Keys.historyRetention) + if historyRetentionMode == .timePeriod { + ClipboardStore.shared.applyHistoryPolicy() + } } } @@ -79,6 +205,8 @@ final class Settings { private init() { d.register(defaults: [ Keys.historyLimit: 500, + Keys.historyRetentionMode: HistoryRetentionMode.itemCount.rawValue, + Keys.historyRetention: HistoryRetention.month.rawValue, Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.launchAtLogin: false, @@ -90,6 +218,8 @@ final class Settings { Keys.iCloudSync: false ]) historyLimit = d.integer(forKey: Keys.historyLimit) + historyRetentionMode = HistoryRetentionMode(rawValue: d.integer(forKey: Keys.historyRetentionMode)) ?? .itemCount + historyRetention = HistoryRetention(rawValue: d.integer(forKey: Keys.historyRetention)) ?? .month hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..982752a 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -26,8 +26,47 @@ private struct GeneralSettings: View { Form { Section("Activation") { LabeledContent("Show Pesty") { HotkeyRecorderView() } - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("History limit", value: "\(settings.historyLimit) items") + } + + Section("History") { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + Spacer() + Text(settings.historyRetention.title) + .fontWeight(.semibold) + .foregroundStyle(Color.accentColor) + } + Slider(value: retentionSliderValue, + in: 0...Double(HistoryRetention.allCases.count - 1), + step: 1) + HStack(spacing: 0) { + ForEach(HistoryRetention.allCases) { retention in + Text(retention.shortSliderTitle) + .font(.caption2.weight(retention == settings.historyRetention ? .bold : .medium)) + .foregroundStyle(retention == settings.historyRetention ? Color.accentColor : .secondary) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) } } @@ -111,6 +150,13 @@ private struct GeneralSettings: View { } } #endif + + private var retentionSliderValue: Binding { + Binding( + get: { settings.historyRetention.sliderIndex }, + set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } + ) + } } private struct AboutView: View { diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..4b9839f 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -18,11 +18,6 @@ final class ClipboardStore { var searchText: String = "" var selectedID: UUID? - var historyLimit: Int { - get { Settings.shared.historyLimit } - set { Settings.shared.historyLimit = newValue; trimHistory() } - } - private var storeURL: URL private var imagesDir: URL private var baseDir: URL @@ -57,6 +52,7 @@ final class ClipboardStore { storeURL = base.appendingPathComponent("store.json") prepareDirectories() load() + if applyHistoryPolicyNow() { saveNow() } if Settings.shared.iCloudSync { startWatching() } } @@ -91,25 +87,37 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = existing.id } scheduleSave() return } history.insert(item, at: 0) - trimHistory() + applyHistoryPolicyNow() if source == .history && searchText.isEmpty { selectedID = item.id } scheduleSave() } - func applyHistoryLimit() { trimHistory(); scheduleSave() } + func applyHistoryPolicy() { _ = applyHistoryPolicyNow(); scheduleSave() } - private func trimHistory() { - guard history.count > historyLimit else { return } - let removed = Array(history[historyLimit...]) - history.removeLast(history.count - historyLimit) + @discardableResult + private func applyHistoryPolicyNow() -> Bool { + let removed: [ClipItem] + switch Settings.shared.historyRetentionMode { + case .itemCount: + guard history.count > Settings.shared.historyLimit else { return false } + removed = Array(history[Settings.shared.historyLimit...]) + history.removeLast(history.count - Settings.shared.historyLimit) + case .timePeriod: + guard let cutoff = Settings.shared.historyRetention.cutoffDate else { return false } + removed = history.filter { $0.createdAt < cutoff } + guard !removed.isEmpty else { return false } + history.removeAll { $0.createdAt < cutoff } + } for item in removed { deleteImageFile(item) } + return true } func delete(_ item: ClipItem) { @@ -298,7 +306,8 @@ final class ClipboardStore { var seen = Set() var merged: [ClipItem] = [] for it in combined where seen.insert(contentKey(it)).inserted { merged.append(it) } - history = Array(merged.prefix(historyLimit)) + history = merged + applyHistoryPolicyNow() var byID: [UUID: Pinboard] = Dictionary(uniqueKeysWithValues: pinboards.map { ($0.id, $0) }) for b in snap.pinboards { From 3a2028aa7bcd8fb51dfb78d2889976cd26aaa8c6 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:55:43 -0700 Subject: [PATCH 10/16] Add click-outside hide preference --- Sources/Pesty/Settings/Settings.swift | 7 +++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/BarWindowController.swift | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..33306b9 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -15,6 +15,7 @@ final class Settings { static let hotkeyKeyCode = "hotkeyKeyCode" static let hotkeyModifiers = "hotkeyModifiers" static let launchAtLogin = "launchAtLogin" + static let hideOnClickOutside = "hideOnClickOutside" static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" @@ -47,6 +48,10 @@ final class Settings { d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) } } + var hideOnClickOutside: Bool { + didSet { guard isLoaded else { return }; d.set(hideOnClickOutside, forKey: Keys.hideOnClickOutside) } + } + var pasteDirectly: Bool { didSet { guard isLoaded else { return }; d.set(pasteDirectly, forKey: Keys.pasteDirectly) } } @@ -82,6 +87,7 @@ final class Settings { Keys.hotkeyKeyCode: kVK_ANSI_V, Keys.hotkeyModifiers: cmdKey | shiftKey, Keys.launchAtLogin: false, + Keys.hideOnClickOutside: true, Keys.pasteDirectly: true, Keys.playSound: false, Keys.ignoreConcealed: true, @@ -93,6 +99,7 @@ final class Settings { hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode) hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers) launchAtLogin = d.bool(forKey: Keys.launchAtLogin) + hideOnClickOutside = d.bool(forKey: Keys.hideOnClickOutside) pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..af8a85c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -37,6 +37,7 @@ private struct GeneralSettings: View { #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) + Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) Toggle("Launch at login", isOn: $settings.launchAtLogin) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..0553b90 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -69,7 +69,9 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { } func windowDidResignKey(_ notification: Notification) { - guard !isPresenting, !AppController.shared.suppressAutoHide else { return } + guard Settings.shared.hideOnClickOutside, + !isPresenting, + !AppController.shared.suppressAutoHide else { return } AppController.shared.hideBar() } } From f60114fa2d9e1529467c1a98f329a8a81bf404d2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:55:57 -0700 Subject: [PATCH 11/16] Add menu bar visibility preference --- Sources/Pesty/AppController.swift | 12 +++++++++++- Sources/Pesty/Settings/Settings.swift | 11 +++++++++++ Sources/Pesty/Settings/SettingsView.swift | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..75afaa9 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -31,7 +31,7 @@ final class AppController: NSObject, NSApplicationDelegate { HotKeyCenter.shared.onTrigger = { [weak self] in self?.toggleBar() } HotKeyCenter.shared.start() - setupStatusItem() + setMenuBarIconVisible(Settings.shared.showMenuBarIcon) if Settings.shared.launchAtLogin { LaunchAtLogin.set(enabled: true) } @@ -63,6 +63,7 @@ final class AppController: NSObject, NSApplicationDelegate { } private func setupStatusItem() { + guard statusItem == nil else { return } let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = item.button { button.image = NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: "Pesty") @@ -82,6 +83,15 @@ final class AppController: NSObject, NSApplicationDelegate { statusItem = item } + func setMenuBarIconVisible(_ visible: Bool) { + if visible { + setupStatusItem() + } else if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + @objc private func menuOpen() { showBar() } @objc private func menuSettings() { showSettings() } @objc private func menuClear() { store.clearHistory() } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..73e0c0d 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -19,6 +19,7 @@ final class Settings { static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" static let barHeight = "barHeight" + static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" } @@ -68,6 +69,14 @@ final class Settings { } } + var showMenuBarIcon: Bool { + didSet { + guard isLoaded else { return } + d.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon) + AppController.shared.setMenuBarIconVisible(showMenuBarIcon) + } + } + var onboarded: Bool { didSet { guard isLoaded else { return }; d.set(onboarded, forKey: Keys.onboarded) } } @@ -86,6 +95,7 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.barHeight: 430.0, + Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false ]) @@ -97,6 +107,7 @@ final class Settings { playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) barHeight = d.double(forKey: Keys.barHeight) + showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) isLoaded = true diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..a3c424c 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -38,6 +38,7 @@ private struct GeneralSettings: View { Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) Toggle("Play sound on paste", isOn: $settings.playSound) Toggle("Launch at login", isOn: $settings.launchAtLogin) + Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) From 9efdab2ff340c357509d82b8f0c824a100c5e54f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:59:14 -0700 Subject: [PATCH 12/16] Add a Quit action to Settings --- Sources/Pesty/Settings/SettingsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..eebd36d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -130,6 +130,10 @@ private struct AboutView: View { Link("Report an Issue", destination: URL(string: "https://github.com/momenbasel/pesty/issues")!) } .padding(.top, 4) + Button("Quit Pesty", role: .destructive) { + NSApp.terminate(nil) + } + .padding(.top, 8) Spacer() Text("MIT Licensed · Made with SwiftUI") .font(.caption).foregroundStyle(.tertiary) From b26d6575f2e7b133c7d87753e6f175b02cfc6f61 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:59:44 -0700 Subject: [PATCH 13/16] Use native switches in Settings --- Sources/Pesty/Settings/SettingsView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..6986e98 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -34,10 +34,14 @@ private struct GeneralSettings: View { Section("Behavior") { #if !MAS Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) + .toggleStyle(.switch) #endif Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) + .toggleStyle(.switch) Toggle("Play sound on paste", isOn: $settings.playSound) + .toggleStyle(.switch) Toggle("Launch at login", isOn: $settings.launchAtLogin) + .toggleStyle(.switch) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) @@ -52,6 +56,7 @@ private struct GeneralSettings: View { Toggle("Sync clipboard via iCloud Drive", isOn: Binding( get: { settings.iCloudSync }, set: { _ in AppController.shared.toggleICloudSync() })) + .toggleStyle(.switch) Text(ClipboardStore.shared.iCloudAvailable ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." : "Sign in to iCloud and enable iCloud Drive to use sync.") From 59e94d4b162217a3432334e76557332803ee8351 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:00:58 -0700 Subject: [PATCH 14/16] Exclude selected apps from clipboard history --- Sources/Pesty/Monitor/ClipboardMonitor.swift | 1 + Sources/Pesty/Settings/Settings.swift | 26 +++++++ Sources/Pesty/Settings/SettingsView.swift | 82 ++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 37e1d99..15b810c 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -50,6 +50,7 @@ final class ClipboardMonitor { } let bundleID = src?.bundleIdentifier let appName = src?.localizedName + guard !Settings.shared.isIgnoringSourceApp(bundleID) else { return nil } func decorate(_ item: inout ClipItem) { item.sourceBundleID = bundleID diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index c0b2a49..bd99eda 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -18,6 +18,7 @@ final class Settings { static let pasteDirectly = "pasteDirectly" static let playSound = "playSound" static let ignoreConcealed = "ignoreConcealed" + static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -59,6 +60,13 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(ignoreConcealed, forKey: Keys.ignoreConcealed) } } + /// Applications whose copied content should never be recorded in history. + /// Store bundle identifiers rather than paths so the choice continues to work + /// when an app is updated or moved. + private(set) var ignoredSourceAppBundleIDs: [String] { + didSet { guard isLoaded else { return }; d.set(ignoredSourceAppBundleIDs, forKey: Keys.ignoredSourceAppBundleIDs) } + } + var barHeight: Double { didSet { guard isLoaded else { return } @@ -85,6 +93,7 @@ final class Settings { Keys.pasteDirectly: true, Keys.playSound: false, Keys.ignoreConcealed: true, + Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, Keys.onboarded: false, Keys.iCloudSync: false @@ -96,6 +105,8 @@ final class Settings { pasteDirectly = d.bool(forKey: Keys.pasteDirectly) playSound = d.bool(forKey: Keys.playSound) ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) + ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) + .filter { !$0.isEmpty } barHeight = d.double(forKey: Keys.barHeight) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) @@ -105,4 +116,19 @@ final class Settings { var hotkeyDisplay: String { HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers) } + + func isIgnoringSourceApp(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return ignoredSourceAppBundleIDs.contains(bundleID) + } + + func addIgnoredSourceApp(_ bundleID: String) { + guard !bundleID.isEmpty, !ignoredSourceAppBundleIDs.contains(bundleID) else { return } + ignoredSourceAppBundleIDs.append(bundleID) + ignoredSourceAppBundleIDs.sort() + } + + func removeIgnoredSourceApp(_ bundleID: String) { + ignoredSourceAppBundleIDs.removeAll { $0 == bundleID } + } } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 2a08bbe..ab000df 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -6,6 +6,8 @@ struct SettingsView: View { TabView { GeneralSettings() .tabItem { Label("General", systemImage: "gearshape") } + PrivacySettings() + .tabItem { Label("Privacy", systemImage: "hand.raised") } AboutView() .tabItem { Label("About", systemImage: "info.circle") } } @@ -13,6 +15,86 @@ struct SettingsView: View { } } +private struct PrivacySettings: View { + @Bindable private var settings = Settings.shared + + var body: some View { + Form { + Section("Excluded Apps") { + Text("Pesty will not save anything copied while one of these apps is active. This is useful for password managers such as 1Password.") + .font(.caption) + .foregroundStyle(.secondary) + + if settings.ignoredSourceAppBundleIDs.isEmpty { + ContentUnavailableView("No apps excluded", + systemImage: "hand.raised", + description: Text("Add an app to keep its copied content out of Pesty.")) + .padding(.vertical, 12) + } else { + ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in + ignoredAppRow(bundleID) + } + } + + Button { chooseApps() } label: { + Label("Add App…", systemImage: "plus") + } + } + } + .formStyle(.grouped) + } + + private func ignoredAppRow(_ bundleID: String) -> some View { + HStack(spacing: 10) { + Image(nsImage: AppIconProvider.icon(forBundleID: bundleID)) + .resizable() + .interpolation(.high) + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text(applicationName(for: bundleID)) + .font(.system(size: 13, weight: .medium)) + Text(bundleID) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + Button { settings.removeIgnoredSourceApp(bundleID) } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Allow clips from \(applicationName(for: bundleID))") + } + .padding(.vertical, 4) + } + + private func chooseApps() { + let panel = NSOpenPanel() + panel.title = "Exclude Apps from Pesty" + panel.message = "Pesty will ignore copied content from the apps you choose." + panel.prompt = "Add Apps" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.applicationBundle] + guard panel.runModal() == .OK else { return } + for url in panel.urls { + guard let bundleID = Bundle(url: url)?.bundleIdentifier, + bundleID != Bundle.main.bundleIdentifier else { continue } + settings.addIgnoredSourceApp(bundleID) + } + } + + private func applicationName(for bundleID: String) -> String { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID), + let bundle = Bundle(url: url) else { return bundleID } + return (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? bundleID + } +} + private struct GeneralSettings: View { @Bindable private var settings = Settings.shared #if !MAS From f8826924717ae0d41972ed7c70a3405707d5b3d2 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:24:48 -0700 Subject: [PATCH 15/16] Polish Settings layout and organization --- Sources/Pesty/AppController.swift | 2 +- Sources/Pesty/Settings/SettingsView.swift | 718 ++++++++++++++-------- 2 files changed, 478 insertions(+), 242 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 949487c..e95dd18 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -195,7 +195,7 @@ final class AppController: NSObject, NSApplicationDelegate { let win = NSWindow(contentViewController: host) win.title = "Pesty Settings" win.styleMask = [.titled, .closable, .miniaturizable] - win.setContentSize(NSSize(width: 680, height: 560)) + win.setContentSize(NSSize(width: 760, height: 680)) win.center() win.isReleasedWhenClosed = false settingsWindow = win diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index dbf178c..0bdc02d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -1,88 +1,343 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers struct SettingsView: View { - private enum Section: String, CaseIterable, Identifiable { - case general - case privacy - case about - - var id: Self { self } - - var title: String { - switch self { - case .general: "General" - case .privacy: "Privacy" - case .about: "About" + @State private var section: SettingsSection = .general + + var body: some View { + HStack(spacing: 0) { + settingsSidebar + Divider() + + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + .font(.system(size: 20, weight: .bold)) + Text(section.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 26) + .padding(.vertical, 18) + + Divider() + content } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) } + .frame(width: 760, height: 680) + .background(Color(nsColor: .windowBackgroundColor)) + } - var symbol: String { - switch self { - case .general: "gearshape" - case .privacy: "hand.raised" - case .about: "info.circle" + private var settingsSidebar: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 9) { + Image(nsImage: NSApp.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + Text("Pesty") + .font(.system(size: 16, weight: .bold)) + } + .padding(.bottom, 18) + + ForEach(SettingsSection.allCases) { item in + Button { section = item } label: { + Label(item.title, systemImage: item.symbol) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + section == item ? Color.accentColor.opacity(0.16) : .clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) } + + Spacer() + + Text("Pesty \(Bundle.main.appVersion)") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) } + .padding(16) + .frame(width: 174) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor)) } - @State private var section: Section = .general + @ViewBuilder + private var content: some View { + switch section { + case .general: + GeneralSettings() + case .privacy: + PrivacySettings() + case .shortcuts: + ShortcutsSettings() + case .sync: + SyncSettings() + case .about: + AboutView() + } + } +} + +private enum SettingsSection: CaseIterable, Identifiable { + case general + case privacy + case shortcuts + case sync + case about + + var id: Self { self } + + var title: String { + switch self { + case .general: "General" + case .privacy: "Privacy" + case .shortcuts: "Shortcuts" + case .sync: "Sync" + case .about: "About" + } + } + + var subtitle: String { + switch self { + case .general: "History, behavior, and app preferences" + case .privacy: "Keep clips from selected apps out of Pesty" + case .shortcuts: "Keyboard controls for Pesty and Quick Paste" + case .sync: "Keep your clipboard history available on every Mac" + case .about: "Pesty for macOS" + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .privacy: "hand.raised" + case .shortcuts: "keyboard" + case .sync: "icloud" + case .about: "info.circle" + } + } +} + +private struct GeneralSettings: View { + @Bindable private var settings = Settings.shared + + #if !MAS + @State private var accessibilityGranted = AXIsProcessTrusted() + @State private var requestedGrant = false + + private let poll = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + #endif var body: some View { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 10) { - Image(nsImage: NSApp.applicationIconImage ?? NSImage()) - .resizable() - .frame(width: 30, height: 30) - Text("Pesty") - .font(.headline) - } - .padding(.horizontal, 16) - .padding(.top, 18) - .padding(.bottom, 14) - - ForEach(Section.allCases) { item in - Button { - section = item - } label: { - Label(item.title, systemImage: item.symbol) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(Rectangle()) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Keep History") { + SettingsSurface { + VStack(alignment: .leading, spacing: 14) { + Picker("Keep history by", selection: $settings.historyRetentionMode) { + ForEach(HistoryRetentionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + + if settings.historyRetentionMode == .itemCount { + Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { + LabeledContent("Number of clips", value: "\(settings.historyLimit) items") + .font(.system(size: 14)) + } + Text("Pesty keeps the most recent \(settings.historyLimit) clips.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Keep clips for") + .font(.system(size: 14)) + Spacer() + Text(settings.historyRetention.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + Slider( + value: retentionSliderValue, + in: 0...Double(HistoryRetention.allCases.count - 1), + step: 1 + ) + HStack(spacing: 0) { + ForEach(HistoryRetention.allCases) { retention in + Text(retention.shortSliderTitle) + .font(.system( + size: 10, + weight: retention == settings.historyRetention ? .bold : .medium + )) + .foregroundStyle( + retention == settings.historyRetention ? Color.accentColor : .secondary + ) + .frame(maxWidth: .infinity) + } + } + } + Text(settings.historyRetention.description) + .font(.caption) + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + Text("Erase saved clips now") + .font(.system(size: 14)) + Spacer() + Button("Erase History…", role: .destructive) { + ClipboardStore.shared.clearHistory() + } + } + } } - .buttonStyle(.plain) - .foregroundStyle(section == item ? .primary : .secondary) - .background { - if section == item { - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) + } + + SettingsFormGroup("Behavior") { + SettingsSurface { + VStack(alignment: .leading, spacing: 0) { + #if !MAS + settingToggle("Paste directly into the active app", isOn: $settings.pasteDirectly) + Divider() + #endif + settingToggle("Play sound on paste", isOn: $settings.playSound) + Divider() + settingToggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) + Divider() + settingToggle("Launch at login", isOn: $settings.launchAtLogin) + Divider() + settingToggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) + Divider() + settingToggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) + Divider() + + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") + .font(.system(size: 14)) + Slider(value: $settings.barHeight, in: 300...720, step: 10) + } + .padding(.vertical, 12) + + #if MAS + Text("Select a clip to copy it, then press ⌘V to paste it into your app.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + #endif } } } - Spacer() - } - .padding(.horizontal, 8) - .frame(width: 180) - .background(Color(nsColor: .windowBackgroundColor)) + SettingsFormGroup("Clip Previews") { + SettingsSurface { + Picker("Preview style", selection: $settings.clipPreviewStyle) { + ForEach(ClipPreviewStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .font(.system(size: 14)) + .padding(.vertical, 9) - Divider() + Divider() + + Text(settings.clipPreviewStyle.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + } + } - Group { - switch section { - case .general: - GeneralSettings() - case .privacy: - PrivacySettings() - case .about: - AboutView() + #if !MAS + SettingsFormGroup("Accessibility") { + SettingsSurface { + HStack(spacing: 12) { + Image(systemName: accessibilityGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(accessibilityGranted ? .green : .orange) + .font(.title3) + VStack(alignment: .leading, spacing: 3) { + Text(accessibilityGranted ? "Accessibility enabled" : "Accessibility required") + .font(.system(size: 14, weight: .medium)) + Text( + accessibilityGranted + ? "Direct paste is ready to use." + : (requestedGrant + ? "Waiting for approval in System Settings." + : "Required to paste directly into other apps.") + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !accessibilityGranted { + Button("Open Settings") { + requestedGrant = true + PasteService.ensureAccessibility(prompt: true) + openAccessibilityPane() + } + } else if requestedGrant { + Button("Restart Pesty") { AppController.restart() } + } + } + } } + #endif } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) } - .frame(width: 680, height: 560) + #if !MAS + .onAppear { accessibilityGranted = AXIsProcessTrusted() } + .onReceive(poll) { _ in + let now = AXIsProcessTrusted() + if now != accessibilityGranted { accessibilityGranted = now } + } + #endif + } + + #if !MAS + private func openAccessibilityPane() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + #endif + + private func settingToggle(_ title: String, isOn: Binding) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.system(size: 14)) + Spacer(minLength: 16) + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } + + private var retentionSliderValue: Binding { + Binding( + get: { settings.historyRetention.sliderIndex }, + set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } + ) } } @@ -90,29 +345,58 @@ private struct PrivacySettings: View { @Bindable private var settings = Settings.shared var body: some View { - Form { - Section("Excluded Apps") { - Text("Pesty will not save anything copied while one of these apps is active. This is useful for password managers such as 1Password.") - .font(.caption) - .foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Excluded Apps") { + SettingsSurface { + Text("Pesty will not save anything copied while one of these apps is the source. This is useful for password managers such as 1Password.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, settings.ignoredSourceAppBundleIDs.isEmpty ? 12 : 8) + + if settings.ignoredSourceAppBundleIDs.isEmpty { + ContentUnavailableView( + "No apps excluded", + systemImage: "hand.raised", + description: Text("Add an app to keep its copied content out of Pesty.") + ) + .font(.system(size: 12)) + .padding(.vertical, 14) + } else { + ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in + Divider() + ignoredAppRow(bundleID) + } + } + + Divider() - if settings.ignoredSourceAppBundleIDs.isEmpty { - ContentUnavailableView("No apps excluded", - systemImage: "hand.raised", - description: Text("Add an app to keep its copied content out of Pesty.")) - .padding(.vertical, 12) - } else { - ForEach(settings.ignoredSourceAppBundleIDs, id: \.self) { bundleID in - ignoredAppRow(bundleID) + Button { chooseApps() } label: { + Label("Add App…", systemImage: "plus") + } + .padding(.vertical, 10) } } - Button { chooseApps() } label: { - Label("Add App…", systemImage: "plus") + SettingsFormGroup("Concealed Clips") { + SettingsSurface { + Toggle("Ignore concealed clipboard content", isOn: $settings.ignoreConcealed) + .font(.system(size: 14)) + .toggleStyle(.switch) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Text("Pesty also respects the standard macOS concealed-clipboard marker used by password managers.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } } } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) } - .formStyle(.grouped) } private func ignoredAppRow(_ bundleID: String) -> some View { @@ -137,7 +421,7 @@ private struct PrivacySettings: View { .buttonStyle(.plain) .help("Allow clips from \(applicationName(for: bundleID))") } - .padding(.vertical, 4) + .padding(.vertical, 7) } private func chooseApps() { @@ -150,6 +434,7 @@ private struct PrivacySettings: View { panel.allowsMultipleSelection = true panel.allowedContentTypes = [.applicationBundle] guard panel.runModal() == .OK else { return } + for url in panel.urls { guard let bundleID = Bundle(url: url)?.bundleIdentifier, bundleID != Bundle.main.bundleIdentifier else { continue } @@ -166,191 +451,138 @@ private struct PrivacySettings: View { } } -private struct GeneralSettings: View { +private struct ShortcutsSettings: View { @Bindable private var settings = Settings.shared - #if !MAS - @State private var accessibilityGranted = AXIsProcessTrusted() - @State private var requestedGrant = false - - private let poll = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - #endif var body: some View { - Form { - Section("Activation") { - LabeledContent("Show Pesty") { HotkeyRecorderView() } - } - - Section("History") { - Picker("Keep history by", selection: $settings.historyRetentionMode) { - ForEach(HistoryRetentionMode.allCases) { mode in - Text(mode.title).tag(mode) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("Open Pesty") { + SettingsSurface { + LabeledContent("Show the Pesty bar") { + HotkeyRecorderView() + } + .font(.system(size: 14)) + .padding(.vertical, 9) } } - .pickerStyle(.segmented) - if settings.historyRetentionMode == .itemCount { - Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) { - LabeledContent("Number of clips", value: "\(settings.historyLimit) items") - } - Text("Pesty keeps the most recent \(settings.historyLimit) clips.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 9) { - HStack { - Text("Keep clips for") - Spacer() - Text(settings.historyRetention.title) - .fontWeight(.semibold) - .foregroundStyle(Color.accentColor) - } - Slider(value: retentionSliderValue, - in: 0...Double(HistoryRetention.allCases.count - 1), - step: 1) - HStack(spacing: 0) { - ForEach(HistoryRetention.allCases) { retention in - Text(retention.shortSliderTitle) - .font(.caption2.weight(retention == settings.historyRetention ? .bold : .medium)) - .foregroundStyle(retention == settings.historyRetention ? Color.accentColor : .secondary) - .frame(maxWidth: .infinity) + SettingsFormGroup("Quick Paste") { + SettingsSurface { + VStack(spacing: 0) { + LabeledContent("Paste items 1–9") { + HStack(spacing: 6) { + ShortcutModifierPicker(selection: $settings.quickPasteModifier) + Text("+ 1…9") + .foregroundStyle(.secondary) + } + } + .font(.system(size: 14)) + .padding(.vertical, 10) + + Divider() + + LabeledContent("Paste as plain text") { + ShortcutModifierPicker(selection: $settings.plainTextModifier) } + .font(.system(size: 14)) + .padding(.vertical, 10) } - } - Text(settings.historyRetention.description) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Section("Quick Paste") { - LabeledContent("Paste items 1–9") { - HStack(spacing: 6) { - modifierPicker(selection: $settings.quickPasteModifier) - Text("+ 1…9").foregroundStyle(.secondary) + Text("Hold the plain-text modifier while using Quick Paste to remove formatting. With the defaults, ⌘⇧1 pastes the first item as plain text.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, 8) } } - LabeledContent("Paste as plain text") { - modifierPicker(selection: $settings.plainTextModifier) - } - Text("Hold the plain-text modifier while using Quick Paste to remove formatting. For example, ⌘⇧1 pastes the first item as plain text with the default shortcuts.") - .font(.caption) - .foregroundStyle(.secondary) } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} - Section("Behavior") { - #if !MAS - Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly) - .toggleStyle(.switch) - #endif - Toggle("Ignore passwords (concealed clips)", isOn: $settings.ignoreConcealed) - .toggleStyle(.switch) - Toggle("Play sound on paste", isOn: $settings.playSound) - .toggleStyle(.switch) - Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) - .toggleStyle(.switch) - Toggle("Launch at login", isOn: $settings.launchAtLogin) - .toggleStyle(.switch) - Toggle("Show resize handle on the Pesty bar", isOn: $settings.showBarResizeHandle) - .toggleStyle(.switch) - Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) - .toggleStyle(.switch) - VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") - Slider(value: $settings.barHeight, in: 300...720, step: 10) - } - #if MAS - Text("Select a clip to copy it, then press ⌘V to paste it into your app.") - .font(.caption).foregroundStyle(.secondary) - #endif - } +private struct SyncSettings: View { + @Bindable private var settings = Settings.shared - Section("Clip previews") { - Picker("Preview style", selection: $settings.clipPreviewStyle) { - ForEach(ClipPreviewStyle.allCases) { style in - Text(style.title).tag(style) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SettingsFormGroup("iCloud Drive") { + SettingsSurface { + Toggle("Sync clipboard via iCloud Drive", isOn: Binding( + get: { settings.iCloudSync }, + set: { _ in AppController.shared.toggleICloudSync() } + )) + .font(.system(size: 14)) + .toggleStyle(.switch) + .padding(.vertical, 10) + + Text( + ClipboardStore.shared.iCloudAvailable + ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." + : "Sign in to iCloud and enable iCloud Drive to use sync." + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 8) } } - .pickerStyle(.segmented) - Text(settings.clipPreviewStyle.detail) - .font(.caption) - .foregroundStyle(.secondary) } + .frame(maxWidth: 548, alignment: .leading) + .padding(24) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} - Section("Sync") { - Toggle("Sync clipboard via iCloud Drive", isOn: Binding( - get: { settings.iCloudSync }, - set: { _ in AppController.shared.toggleICloudSync() })) - .toggleStyle(.switch) - Text(ClipboardStore.shared.iCloudAvailable - ? "Keeps your history and pinboards in sync across your Macs through iCloud Drive." - : "Sign in to iCloud and enable iCloud Drive to use sync.") - .font(.caption).foregroundStyle(.secondary) - } +private struct SettingsFormGroup: View { + let title: String + @ViewBuilder let content: Content - #if !MAS - Section("Permissions") { - HStack(spacing: 10) { - Image(systemName: accessibilityGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundStyle(accessibilityGranted ? .green : .orange) - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text("Accessibility") - Text(accessibilityGranted - ? "Granted — direct paste is enabled." - : (requestedGrant - ? "Waiting… toggle Pesty on in System Settings." - : "Required to paste directly into other apps.")) - .font(.caption) - .foregroundStyle(accessibilityGranted ? .green : .secondary) - } - Spacer() - if !accessibilityGranted { - Button("Open Settings") { - requestedGrant = true - PasteService.ensureAccessibility(prompt: true) - openAccessibilityPane() - } - } else if requestedGrant { - Button("Restart Pesty") { AppController.restart() } - } - } - } - #endif + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } - Section("Data") { - Button("Clear Clipboard History", role: .destructive) { - ClipboardStore.shared.clearHistory() - } - } - } - .formStyle(.grouped) - #if !MAS - .onAppear { accessibilityGranted = AXIsProcessTrusted() } - .onReceive(poll) { _ in - let now = AXIsProcessTrusted() - if now != accessibilityGranted { accessibilityGranted = now } + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + content } - #endif } +} - #if !MAS - private func openAccessibilityPane() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) - } +private struct SettingsSurface: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() } - #endif - private var retentionSliderValue: Binding { - Binding( - get: { settings.historyRetention.sliderIndex }, - set: { settings.historyRetention = HistoryRetention(sliderIndex: $0) } - ) + var body: some View { + VStack(alignment: .leading, spacing: 0) { content } + .padding(.horizontal, 16) + .padding(.vertical, 4) + .background( + Color(nsColor: .windowBackgroundColor), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.primary.opacity(0.08)) + } } +} + +private struct ShortcutModifierPicker: View { + @Binding var selection: Int - private func modifierPicker(selection: Binding) -> some View { - Picker("", selection: selection) { + var body: some View { + Picker("", selection: $selection) { ForEach(ShortcutModifier.allCases) { modifier in Text("\(modifier.symbol) \(modifier.title)").tag(modifier.carbonValue) } @@ -365,10 +597,13 @@ private struct AboutView: View { var body: some View { VStack(spacing: 12) { Image(nsImage: NSApp.applicationIconImage ?? NSImage()) - .resizable().frame(width: 88, height: 88) - Text("Pesty").font(.system(size: 26, weight: .bold)) + .resizable() + .frame(width: 88, height: 88) + Text("Pesty") + .font(.system(size: 26, weight: .bold)) Text("Version \(Bundle.main.appVersion)") - .font(.subheadline).foregroundStyle(.secondary) + .font(.subheadline) + .foregroundStyle(.secondary) Text("A free, open-source clipboard manager for macOS.\nInspired by Paste.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) @@ -384,7 +619,8 @@ private struct AboutView: View { .padding(.top, 8) Spacer() Text("MIT Licensed · Made with SwiftUI") - .font(.caption).foregroundStyle(.tertiary) + .font(.caption) + .foregroundStyle(.tertiary) } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) From 0175a9e11c0bbf321a3eabbfb0f4569c2796949f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 22:39:13 -0700 Subject: [PATCH 16/16] Add selected clip position preference --- Sources/Pesty/Settings/Settings.swift | 28 +++++++++++++++++++++++ Sources/Pesty/Settings/SettingsView.swift | 20 ++++++++++++++++ Sources/Pesty/UI/BarView.swift | 12 +++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index b1ecc7f..77c666e 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -23,6 +23,27 @@ enum ClipPreviewStyle: Int, CaseIterable, Identifiable { } } +enum SelectedClipPosition: Int, CaseIterable, Identifiable { + case center + case rightEdge + + var id: Int { rawValue } + + var title: String { + switch self { + case .center: "Center" + case .rightEdge: "Right edge" + } + } + + var detail: String { + switch self { + case .center: "Keep the selected clip centered with surrounding context visible." + case .rightEdge: "Place the selected clip at the far right, like Paste." + } + } +} + enum HistoryRetention: Int, CaseIterable, Identifiable { case day case week @@ -197,6 +218,7 @@ final class Settings { static let barHeight = "barHeight" static let showBarResizeHandle = "showBarResizeHandle" static let clipPreviewStyle = "clipPreviewStyle" + static let selectedClipPosition = "selectedClipPosition" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -293,6 +315,10 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(clipPreviewStyle.rawValue, forKey: Keys.clipPreviewStyle) } } + var selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -327,6 +353,7 @@ final class Settings { Keys.barHeight: 430.0, Keys.showBarResizeHandle: false, Keys.clipPreviewStyle: ClipPreviewStyle.nativeQuickLook.rawValue, + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false @@ -348,6 +375,7 @@ final class Settings { barHeight = d.double(forKey: Keys.barHeight) showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) clipPreviewStyle = ClipPreviewStyle(rawValue: d.integer(forKey: Keys.clipPreviewStyle)) ?? .nativeQuickLook + selectedClipPosition = SelectedClipPosition(rawValue: d.integer(forKey: Keys.selectedClipPosition)) ?? .center showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 0bdc02d..a7e198a 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -245,6 +245,26 @@ private struct GeneralSettings: View { } } + SettingsFormGroup("Clip Navigation") { + SettingsSurface { + Picker("Selected clip position", selection: $settings.selectedClipPosition) { + ForEach(SelectedClipPosition.allCases) { position in + Text(position.title).tag(position) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .padding(.vertical, 9) + + Divider() + + Text(settings.selectedClipPosition.detail) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 9) + } + } + SettingsFormGroup("Clip Previews") { SettingsSurface { Picker("Preview style", selection: $settings.clipPreviewStyle) { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 967b525..741f69a 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -153,7 +153,13 @@ struct BarView: View { .onChange(of: store.selectedID) { _, id in guard let id else { return } withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { - proxy.scrollTo(id, anchor: .center) + proxy.scrollTo(id, anchor: selectedClipAnchor) + } + } + .onChange(of: settings.selectedClipPosition) { _, _ in + guard let id = store.selectedID else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { + proxy.scrollTo(id, anchor: selectedClipAnchor) } } .overlay { if store.visibleItems.isEmpty { emptyState } } @@ -161,6 +167,10 @@ struct BarView: View { .frame(maxHeight: .infinity) } + private var selectedClipAnchor: UnitPoint { + settings.selectedClipPosition == .rightEdge ? .trailing : .center + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: store.searchText.isEmpty ? "doc.on.clipboard" : "magnifyingglass")