From c84455460675617b8db40253241d2559accb2f83 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:48:54 -0700 Subject: [PATCH 1/6] 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 30cfff47e0f10d92c3a7314bf5cb62b8aeb9bdd9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 19:58:13 -0700 Subject: [PATCH 2/6] 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 3/6] 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 e3770df72d8221457e4692a00d2f70b1f8bff3c4 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:32:24 -0700 Subject: [PATCH 4/6] Add anchored link preview popover --- Sources/Pesty/AppController.swift | 18 ++- Sources/Pesty/UI/BarView.swift | 42 ++++-- Sources/Pesty/UI/BarWindowController.swift | 10 ++ Sources/Pesty/UI/ClipCardView.swift | 14 +- Sources/Pesty/UI/ClipPreviewViews.swift | 158 +++++++++++++++++++++ Sources/Pesty/Util/LinkPreviewStore.swift | 30 +++- 6 files changed, 242 insertions(+), 30 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index e8135e5..455a02e 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -149,9 +149,23 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() store.inlinePreviewVisible = false + barController?.setInlinePreviewVisible(false) barController?.hide() } + func toggleInlinePreview() { + guard Settings.shared.clipPreviewStyle == .inlinePesty, + store.selectedItem != nil else { return } + store.inlinePreviewVisible.toggle() + barController?.setInlinePreviewVisible(store.inlinePreviewVisible) + } + + func hideInlinePreview() { + guard store.inlinePreviewVisible else { return } + store.inlinePreviewVisible = false + barController?.setInlinePreviewVisible(false) + } + func pasteSelected() { guard let item = store.selectedItem else { return } hideBar() @@ -216,8 +230,8 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_Space where store.searchText.isEmpty: if Settings.shared.clipPreviewStyle == .nativeQuickLook { QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) - } else if store.selectedItem != nil { - store.inlinePreviewVisible.toggle() + } else { + toggleInlinePreview() } return nil case kVK_Escape: diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index f2c13c7..d32478b 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,26 +3,33 @@ import SwiftUI struct BarView: View { @Bindable private var store = ClipboardStore.shared @Bindable private var settings = Settings.shared + @State private var cardFrames: [UUID: CGRect] = [:] var body: some View { ZStack { VisualEffectView(material: .hudWindow) Theme.panelTint - } - .overlay(alignment: .top) { VStack(spacing: 0) { topBar - HStack(spacing: 0) { - if settings.clipPreviewStyle == .inlinePesty, - store.inlinePreviewVisible, - let item = store.selectedItem { - SelectedClipPreviewView(item: item) - Divider() - } + if settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible { + Spacer(minLength: 0) + strip.frame(height: 280) + } else { strip } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + if settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible, + let item = store.selectedItem { + PestyPreviewPopover( + item: item, + pointer: cardFrames[item.id].map { CGPoint(x: $0.midX, y: $0.midY) } ?? .zero) + } } + .coordinateSpace(name: "PestyBar") + .onPreferenceChange(ClipCardFramePreferenceKey.self) { cardFrames = $0 } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() } @@ -42,7 +49,7 @@ struct BarView: View { } private var previewButton: some View { - Button { store.inlinePreviewVisible.toggle() } label: { + Button { AppController.shared.toggleInlinePreview() } label: { Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) @@ -115,6 +122,13 @@ struct BarView: View { index: index, selected: item.id == store.selectedID) .id(item.id) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: ClipCardFramePreferenceKey.self, + value: [item.id: proxy.frame(in: .named("PestyBar"))]) + } + } .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), removal: .opacity)) @@ -150,6 +164,14 @@ struct BarView: View { } } +private struct ClipCardFramePreferenceKey: PreferenceKey { + static var defaultValue: [UUID: CGRect] = [:] + + static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + struct RoundedCorners: Shape { var radius: CGFloat var corners: RectCorner diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 3661525..8e977f5 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -68,6 +68,16 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { }) } + func setInlinePreviewVisible(_ visible: Bool) { + 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 frame = screen.visibleFrame + let height = visible ? max(CGFloat(Settings.shared.barHeight), 620) : CGFloat(Settings.shared.barHeight) + panel.setFrame(NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: height), display: true) + } + func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } // Quick Look becomes key immediately after the strip hands it a preview. diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..6a85436 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -106,13 +106,8 @@ struct ClipCardView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) case .link: - VStack(spacing: 10) { - Spacer(minLength: 0) - Image(systemName: "safari").font(.system(size: 34, weight: .light)) - .foregroundStyle(Theme.textTertiary) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) + LinkCardPreview(text: item.text ?? item.displayTitle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) default: Text(item.text ?? "") .font(.system(size: 12.5)) @@ -130,11 +125,6 @@ struct ClipCardView: View { private var footer: some View { VStack(alignment: .leading, spacing: 3) { - if item.type == .link { - Text(item.displayTitle) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Theme.textPrimary).lineLimit(1) - } HStack(spacing: 6) { Text(metaLeft) .font(.system(size: 11)) diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 438c16f..b107cdb 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import WebKit struct RichTextContent: View { let rtfData: Data? @@ -78,6 +79,163 @@ struct LinkPreviewContent: View { } } +struct LinkCardPreview: View { + let text: String + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Group { + if let image = preview?.image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFill() + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .overlay { + Image(systemName: "link") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 104) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack(spacing: 7) { + if let icon = preview?.icon { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } else { + Image(systemName: "link") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16, height: 16) + } + Text(preview?.title ?? host) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + } + } + .onAppear { previews.load(for: url) } + } +} + +struct PestyPreviewPopover: View { + let item: ClipItem + let pointer: CGPoint + + private var url: URL? { + guard item.type == .link else { return nil } + return URL(string: (item.text ?? item.displayTitle).trimmingCharacters(in: .whitespacesAndNewlines)) + } + + var body: some View { + GeometryReader { proxy in + let inset: CGFloat = 24 + let width = max(280, min(980, proxy.size.width - inset * 2)) + let targetX = pointer.x > 0 ? pointer.x : proxy.size.width / 2 + let left = min(max(inset, targetX - width / 2), max(inset, proxy.size.width - width - inset)) + let cardTop = pointer.y > 0 ? pointer.y : proxy.size.height - 44 + let height = min(520, max(220, cardTop - 84)) + let totalHeight = height + 13 + let arrowOffset = min(width / 2 - 22, max(-width / 2 + 22, targetX - left - width / 2)) + let centerY = max(20 + totalHeight / 2, cardTop - 12 - totalHeight / 2) + + VStack(spacing: 0) { + previewPanel + .frame(height: height) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(.white.opacity(0.22)) + } + PreviewPointer() + .fill(Color(nsColor: .windowBackgroundColor)) + .frame(width: 24, height: 13) + .offset(x: arrowOffset) + } + .frame(width: width) + .shadow(color: .black.opacity(0.38), radius: 24, y: 10) + .position(x: left + width / 2, y: centerY) + } + .allowsHitTesting(true) + } + + private var previewPanel: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { AppController.shared.hideInlinePreview() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Text(item.type.label) + .font(.system(size: 17, weight: .bold)) + Spacer() + if let url { + Button("Open in Safari") { NSWorkspace.shared.open(url) } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + + Divider() + + Group { + if let url { + WebLinkPreview(url: url) + } else { + SelectedClipPreviewView(item: item) + } + } + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .padding(10) + } + } +} + +private struct WebLinkPreview: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.load(URLRequest(url: url)) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard webView.url != url else { return } + webView.load(URLRequest(url: url)) + } +} + +private struct PreviewPointer: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + struct SelectedClipPreviewView: View { let item: ClipItem private var store: ClipboardStore { ClipboardStore.shared } diff --git a/Sources/Pesty/Util/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift index 4702bec..7bfb1bb 100644 --- a/Sources/Pesty/Util/LinkPreviewStore.swift +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -5,6 +5,7 @@ import Observation struct LinkPreview { var title: String? var icon: NSImage? + var image: NSImage? } @Observable @@ -34,9 +35,17 @@ final class LinkPreviewStore { request.timeoutInterval = 5 request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { data, _, _ in - let title = data.flatMap(Self.pageTitle(from:)) + let metadata = data.flatMap { Self.pageMetadata(from: $0, relativeTo: url) } DispatchQueue.main.async { - self.update(host: host, title: title, icon: nil, finished: false) + self.update(host: host, title: metadata?.title, icon: nil, image: nil, finished: false) + } + if let imageURL = metadata?.imageURL { + URLSession.shared.dataTask(with: imageURL) { imageData, _, _ in + let image = imageData.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: nil, image: image, finished: false) + } + }.resume() } }.resume() @@ -51,27 +60,36 @@ final class LinkPreviewStore { URLSession.shared.dataTask(with: iconURL) { data, _, _ in let icon = data.flatMap(NSImage.init(data:)) DispatchQueue.main.async { - self.update(host: host, title: nil, icon: icon, finished: true) + self.update(host: host, title: nil, icon: icon, image: nil, finished: true) } }.resume() } - private func update(host: String, title: String?, icon: NSImage?, finished: Bool) { + private func update(host: String, title: String?, icon: NSImage?, image: NSImage?, finished: Bool) { var preview = previews[host] ?? LinkPreview() if let title, !title.isEmpty { preview.title = title } if let icon { preview.icon = icon } + if let image { preview.image = image } previews[host] = preview if finished { loadingHosts.remove(host) } } - nonisolated private static func pageTitle(from data: Data) -> String? { + nonisolated private static func pageMetadata(from data: Data, relativeTo url: URL) -> (title: String?, imageURL: URL?)? { guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), let expression = try? NSRegularExpression(pattern: "]*>(.*?)", options: [.caseInsensitive, .dotMatchesLineSeparators]), let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), let range = Range(match.range(at: 1), in: html) else { return nil } - return html[range] + let title = html[range] .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) + let imagePattern = "]+(?:property|name)=[\\\"'](?:og:image|twitter:image)[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']" + let imageExpression = try? NSRegularExpression(pattern: imagePattern, options: [.caseInsensitive]) + let imageURL: URL? = imageExpression.flatMap { expression in + guard let imageMatch = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let imageRange = Range(imageMatch.range(at: 1), in: html) else { return nil } + return URL(string: String(html[imageRange]), relativeTo: url)?.absoluteURL + } + return (title.isEmpty ? nil : title, imageURL) } } From 8d73223917ab457caf0fbe62db22b79235e64252 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 08:09:25 -0700 Subject: [PATCH 5/6] Improve floating inline previews --- Sources/Pesty/AppController.swift | 23 +- Sources/Pesty/Models/ClipItem.swift | 20 ++ Sources/Pesty/UI/BarView.swift | 36 +-- Sources/Pesty/UI/BarWindowController.swift | 16 +- Sources/Pesty/UI/ClipCardView.swift | 32 ++- Sources/Pesty/UI/ClipPreviewViews.swift | 205 +++++++++++------- .../UI/InlinePreviewWindowController.swift | 96 ++++++++ Sources/Pesty/Util/QuickLookService.swift | 12 +- 8 files changed, 314 insertions(+), 126 deletions(-) create mode 100644 Sources/Pesty/UI/InlinePreviewWindowController.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 455a02e..5181588 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -10,6 +10,7 @@ final class AppController: NSObject, NSApplicationDelegate { let monitor = ClipboardMonitor() private var barController: BarWindowController? + private var inlinePreviewController: InlinePreviewWindowController? private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? private var keyMonitor: Any? @@ -138,6 +139,7 @@ final class AppController: NSObject, NSApplicationDelegate { store.source = .history store.selectFirst() store.inlinePreviewVisible = false + inlinePreviewController?.hide() if barController == nil { barController = BarWindowController() @@ -149,21 +151,34 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() store.inlinePreviewVisible = false - barController?.setInlinePreviewVisible(false) + inlinePreviewController?.hide() barController?.hide() } func toggleInlinePreview() { guard Settings.shared.clipPreviewStyle == .inlinePesty, store.selectedItem != nil else { return } - store.inlinePreviewVisible.toggle() - barController?.setInlinePreviewVisible(store.inlinePreviewVisible) + if store.inlinePreviewVisible { + hideInlinePreview() + } else { + store.inlinePreviewVisible = true + } } func hideInlinePreview() { guard store.inlinePreviewVisible else { return } store.inlinePreviewVisible = false - barController?.setInlinePreviewVisible(false) + inlinePreviewController?.hide() + } + + func updateInlinePreview(item: ClipItem, cardFrame: CGRect) { + guard store.inlinePreviewVisible, + let barPanel = barController?.window, + barPanel.isVisible else { return } + if inlinePreviewController == nil { + inlinePreviewController = InlinePreviewWindowController() + } + inlinePreviewController?.show(item: item, anchoredTo: cardFrame, in: barPanel) } func pasteSelected() { diff --git a/Sources/Pesty/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index b3689df..574afb7 100644 --- a/Sources/Pesty/Models/ClipItem.swift +++ b/Sources/Pesty/Models/ClipItem.swift @@ -1,4 +1,5 @@ import AppKit +import UniformTypeIdentifiers struct ClipItem: Identifiable, Codable, Equatable { let id: UUID @@ -44,6 +45,25 @@ struct ClipItem: Identifiable, Codable, Equatable { var charCount: Int { text?.count ?? 0 } + /// Files copied from Finder remain file clips so they paste back as files, + /// but image files should be presented to the person as images. + var imageFileURL: URL? { + guard type == .file, + fileURLs.count == 1, + let value = fileURLs.first, + let url = URL(string: value), + url.isFileURL, + let fileType = UTType(filenameExtension: url.pathExtension), + fileType.conforms(to: .image) else { return nil } + return url + } + + var isImageFile: Bool { imageFileURL != nil } + + var presentationType: ClipType { + isImageFile ? .image : type + } + var displayTitle: String { if let t = customTitle, !t.isEmpty { return t } switch type { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index d32478b..922b918 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -11,25 +11,23 @@ struct BarView: View { Theme.panelTint VStack(spacing: 0) { topBar - if settings.clipPreviewStyle == .inlinePesty, - store.inlinePreviewVisible { - Spacer(minLength: 0) - strip.frame(height: 280) - } else { - strip - } + strip } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - if settings.clipPreviewStyle == .inlinePesty, - store.inlinePreviewVisible, - let item = store.selectedItem { - PestyPreviewPopover( - item: item, - pointer: cardFrames[item.id].map { CGPoint(x: $0.midX, y: $0.midY) } ?? .zero) - } } .coordinateSpace(name: "PestyBar") - .onPreferenceChange(ClipCardFramePreferenceKey.self) { cardFrames = $0 } + .onPreferenceChange(ClipCardFramePreferenceKey.self) { + cardFrames = $0 + updateExternalPreview() + } + .onChange(of: store.inlinePreviewVisible) { _, visible in + guard visible else { return } + DispatchQueue.main.async { updateExternalPreview() } + } + .onChange(of: store.selectedID) { _, _ in + guard store.inlinePreviewVisible else { return } + DispatchQueue.main.async { updateExternalPreview() } + } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() } @@ -162,6 +160,14 @@ struct BarView: View { .foregroundStyle(Theme.textSecondary) } } + + private func updateExternalPreview() { + guard settings.clipPreviewStyle == .inlinePesty, + store.inlinePreviewVisible, + let item = store.selectedItem, + let cardFrame = cardFrames[item.id] else { return } + AppController.shared.updateInlinePreview(item: item, cardFrame: cardFrame) + } } private struct ClipCardFramePreferenceKey: PreferenceKey { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 8e977f5..f055ef6 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -68,23 +68,15 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { }) } - func setInlinePreviewVisible(_ visible: Bool) { - 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 frame = screen.visibleFrame - let height = visible ? max(CGFloat(Settings.shared.barHeight), 620) : CGFloat(Settings.shared.barHeight) - panel.setFrame(NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: height), display: true) - } - func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } - // Quick Look becomes key immediately after the strip hands it a preview. + // Quick Look and the separate inline preview can become key while Pesty + // remains open behind them. // Defer until that transition is visible before deciding whether focus // actually left Pesty. DispatchQueue.main.async { - guard !QuickLookService.shared.isVisible else { return } + guard !QuickLookService.shared.isVisible, + !ClipboardStore.shared.inlinePreviewVisible else { return } AppController.shared.hideBar() } } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 6a85436..939bcc1 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -8,6 +8,10 @@ struct ClipCardView: View { @State private var hovering = false private var store: ClipboardStore { ClipboardStore.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } + private var presentationType: ClipType { item.presentationType } + private var imageFile: NSImage? { + item.imageFileURL.flatMap(NSImage.init(contentsOf:)) + } var body: some View { VStack(spacing: 0) { @@ -38,7 +42,7 @@ struct ClipCardView: View { headerColor HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 2) { - Text(item.type.label) + Text(presentationType.label) .font(.system(size: 14, weight: .bold)) .foregroundStyle(Theme.headerText) Text(item.createdAt.clipRelativeLong) @@ -97,14 +101,22 @@ struct ClipCardView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) case .file: - VStack(spacing: 9) { - Image(systemName: "doc.fill").font(.system(size: 32)) - .foregroundStyle(headerColor) - Text(item.displayTitle).font(.system(size: 12)) - .foregroundStyle(Theme.textSecondary).lineLimit(2) - .multilineTextAlignment(.center) + if let imageFile { + Image(nsImage: imageFile) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 9) { + Image(systemName: "doc.fill").font(.system(size: 32)) + .foregroundStyle(headerColor) + Text(item.displayTitle).font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary).lineLimit(2) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) case .link: LinkCardPreview(text: item.text ?? item.displayTitle) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -153,7 +165,9 @@ struct ClipCardView: View { return (item.text ?? "").replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "") case .file: - return "\(item.fileURLs.count) file\(item.fileURLs.count == 1 ? "" : "s")" + return item.isImageFile + ? "Image" + : "\(item.fileURLs.count) file\(item.fileURLs.count == 1 ? "" : "s")" case .image: return "Image" case .color: diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index b107cdb..9a60cda 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -134,66 +134,82 @@ struct LinkCardPreview: View { struct PestyPreviewPopover: View { let item: ClipItem - let pointer: CGPoint + let pointerOffset: CGFloat + + @Environment(\.colorScheme) private var colorScheme + @State private var hasAppeared = false private var url: URL? { guard item.type == .link else { return nil } return URL(string: (item.text ?? item.displayTitle).trimmingCharacters(in: .whitespacesAndNewlines)) } + private var surfaceColor: Color { Color(nsColor: .windowBackgroundColor) } + private var documentColor: Color { Color(nsColor: .textBackgroundColor) } + private var chromeBorder: Color { + colorScheme == .dark ? .white.opacity(0.16) : .black.opacity(0.13) + } + var body: some View { - GeometryReader { proxy in - let inset: CGFloat = 24 - let width = max(280, min(980, proxy.size.width - inset * 2)) - let targetX = pointer.x > 0 ? pointer.x : proxy.size.width / 2 - let left = min(max(inset, targetX - width / 2), max(inset, proxy.size.width - width - inset)) - let cardTop = pointer.y > 0 ? pointer.y : proxy.size.height - 44 - let height = min(520, max(220, cardTop - 84)) - let totalHeight = height + 13 - let arrowOffset = min(width / 2 - 22, max(-width / 2 + 22, targetX - left - width / 2)) - let centerY = max(20 + totalHeight / 2, cardTop - 12 - totalHeight / 2) - - VStack(spacing: 0) { - previewPanel - .frame(height: height) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(.white.opacity(0.22)) - } - PreviewPointer() - .fill(Color(nsColor: .windowBackgroundColor)) - .frame(width: 24, height: 13) - .offset(x: arrowOffset) + VStack(spacing: 0) { + previewPanel + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(surfaceColor, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder(chromeBorder) + } + PreviewPointer() + .fill(surfaceColor) + .frame(width: 26, height: 12) + .offset(x: pointerOffset) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .opacity(hasAppeared ? 1 : 0) + .scaleEffect(hasAppeared ? 1 : 0.84, anchor: .bottom) + .offset(y: hasAppeared ? 0 : 24) + .onAppear { + withAnimation(.spring(response: 0.44, dampingFraction: 0.62, blendDuration: 0.12)) { + hasAppeared = true } - .frame(width: width) - .shadow(color: .black.opacity(0.38), radius: 24, y: 10) - .position(x: left + width / 2, y: centerY) } .allowsHitTesting(true) } private var previewPanel: some View { VStack(spacing: 0) { - HStack(spacing: 12) { + HStack(spacing: 11) { Button { AppController.shared.hideInlinePreview() } label: { Image(systemName: "xmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 19, weight: .semibold)) .foregroundStyle(.secondary) } .buttonStyle(.plain) - Text(item.type.label) - .font(.system(size: 17, weight: .bold)) + Text(item.presentationType.label) + .font(.system(size: 18, weight: .bold)) Spacer() + Menu { + Button("Copy") { AppController.shared.copyItem(item) } + Button("Paste") { AppController.shared.pasteItem(item) } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 30, height: 30) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) if let url { Button("Open in Safari") { NSWorkspace.shared.open(url) } .buttonStyle(.bordered) + .controlSize(.regular) } } - .padding(.horizontal, 16) - .frame(height: 52) + .padding(.horizontal, 20) + .frame(height: 56) - Divider() + Divider().overlay(chromeBorder) Group { if let url { @@ -202,9 +218,60 @@ struct PestyPreviewPopover: View { SelectedClipPreviewView(item: item) } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(documentColor, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) - .padding(10) + .padding(.horizontal, 12) + .padding(.top, 12) + + PreviewMetadataFooter(item: item) + .padding(.horizontal, 22) + .frame(height: 42) + } + } +} + +private struct PreviewMetadataFooter: View { + let item: ClipItem + + private var metrics: [String] { + if item.isImageFile { + return ["Image", item.createdAt.clipRelativeLong] + } + switch item.type { + case .text, .richText, .link: + let text = item.text ?? "" + let characters = text.count + let words = text.split { $0.isWhitespace || $0.isNewline }.count + let lines = max(1, text.split(separator: "\n", omittingEmptySubsequences: false).count) + return [ + "\(characters) character\(characters == 1 ? "" : "s")", + "\(words) word\(words == 1 ? "" : "s")", + "\(lines) line\(lines == 1 ? "" : "s")" + ] + case .image: + return ["Image", item.createdAt.clipRelativeLong] + case .file: + return ["\(item.fileURLs.count) file\(item.fileURLs.count == 1 ? "" : "s")", item.createdAt.clipRelativeLong] + case .color: + return [item.colorHex ?? "Color", item.createdAt.clipRelativeLong] + } + } + + var body: some View { + HStack(spacing: 10) { + ForEach(Array(metrics.enumerated()), id: \.offset) { index, metric in + if index > 0 { + Text("·") + .foregroundStyle(.tertiary) + } + Text(metric) + } + Spacer(minLength: 0) } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) } } @@ -241,29 +308,8 @@ struct SelectedClipPreviewView: View { 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)) + previewContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @ViewBuilder @@ -276,24 +322,20 @@ struct SelectedClipPreviewView: View { .interpolation(.high) .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(14) } else { missingPreview("photo") } case .richText: ScrollView { - RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", font: .system(size: 15)) - .foregroundStyle(Theme.textPrimary) + RichTextContent(rtfData: item.rtfData, fallback: item.text ?? "", font: .system(size: 26)) + .foregroundStyle(Color.primary) + .lineSpacing(6) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) + .padding(28) } 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() - } + LinkPreviewContent(text: item.text ?? item.displayTitle, compact: false) + .padding(28) case .file: if let image = filePreviewImage { Image(nsImage: image) @@ -301,43 +343,46 @@ struct SelectedClipPreviewView: View { .interpolation(.high) .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(14) } else { - VStack(spacing: 12) { + VStack(spacing: 14) { Image(systemName: "doc.fill") - .font(.system(size: 46, weight: .light)) + .font(.system(size: 52, weight: .light)) .foregroundStyle(item.type.accent) Text(item.displayTitle) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 18, weight: .medium)) .multilineTextAlignment(.center) - .foregroundStyle(Theme.textPrimary) - Spacer() + .foregroundStyle(Color.primary) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .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)) + .font(.system(size: 26, weight: .bold, design: .monospaced)) .foregroundStyle(.white) .shadow(radius: 2) } + .padding(22) case .text: ScrollView { Text(item.text ?? "") - .font(.system(size: 15)) - .foregroundStyle(Theme.textPrimary) + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(Color.primary) + .lineSpacing(7) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) + .padding(28) } } } private func missingPreview(_ symbol: String) -> some View { Image(systemName: symbol) - .font(.system(size: 38, weight: .light)) - .foregroundStyle(Theme.textTertiary) + .font(.system(size: 44, weight: .light)) + .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/Pesty/UI/InlinePreviewWindowController.swift b/Sources/Pesty/UI/InlinePreviewWindowController.swift new file mode 100644 index 0000000..be7e41c --- /dev/null +++ b/Sources/Pesty/UI/InlinePreviewWindowController.swift @@ -0,0 +1,96 @@ +import AppKit +import SwiftUI + +private final class InlinePreviewPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +/// Hosts the Inline Pesty preview in its own floating panel. Keeping it separate +/// from the bar lets the card strip retain its normal size and makes the preview +/// read as a document window anchored to the selected clip. +@MainActor +final class InlinePreviewWindowController: NSWindowController { + private let hostingView: NSHostingView + private var currentItemID: UUID? + private var currentPointerOffset: CGFloat = 0 + + init() { + let host = NSHostingView(rootView: AnyView(EmptyView())) + hostingView = host + + let panel = InlinePreviewPanel( + contentRect: NSRect(x: 0, y: 0, width: 820, height: 430), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isFloatingPanel = true + panel.level = NSWindow.Level(rawValue: NSWindow.Level.modalPanel.rawValue + 1) + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.isMovable = false + panel.contentView = host + + super.init(window: panel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + func show(item: ClipItem, anchoredTo cardFrame: CGRect, in barPanel: NSWindow) { + guard let panel = window, + let screen = barPanel.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(barPanel.frame) }) + ?? NSScreen.main else { return } + + let cardOnScreen = NSRect( + x: barPanel.frame.minX + cardFrame.minX, + y: barPanel.frame.minY + barPanel.frame.height - cardFrame.maxY, + width: cardFrame.width, + height: cardFrame.height) + let presentation = presentationFrame(for: cardOnScreen, on: screen) + + let needsNewContent = !panel.isVisible + || currentItemID != item.id + || abs(currentPointerOffset - presentation.pointerOffset) > 1 + if needsNewContent { + hostingView.rootView = AnyView( + PestyPreviewPopover(item: item, pointerOffset: presentation.pointerOffset) + ) + currentItemID = item.id + currentPointerOffset = presentation.pointerOffset + } + + panel.setFrame(presentation.frame, display: true) + panel.makeKeyAndOrderFront(nil) + } + + func hide() { + window?.orderOut(nil) + } + + private func presentationFrame(for card: NSRect, on screen: NSScreen) -> (frame: NSRect, pointerOffset: CGFloat) { + let visible = screen.visibleFrame + let horizontalInset: CGFloat = 34 + let width = min(1_180, max(360, visible.width - horizontalInset * 2)) + let minX = visible.minX + horizontalInset + let maxX = visible.maxX - horizontalInset - width + let desiredX = card.midX - width / 2 + let x = min(max(minX, desiredX), maxX) + + // The pointer tip lands immediately above the card header, while the + // document sheet extends upward into its own window above the bar. + let arrowTipY = card.maxY - 2 + let availableHeight = visible.maxY - arrowTipY - 30 + let height = min(470, max(250, availableHeight)) + let pointerLimit = max(0, width / 2 - 42) + let pointerOffset = min(pointerLimit, max(-pointerLimit, card.midX - (x + width / 2))) + + return ( + NSRect(x: x, y: arrowTipY, width: width, height: height), + pointerOffset + ) + } +} diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift index 0479013..0d8632f 100644 --- a/Sources/Pesty/Util/QuickLookService.swift +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -57,14 +57,14 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource switch clip.type { case .file: let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) - if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.type.label) } } + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.presentationType.label) } } case .image: if let url = ClipboardStore.shared.imageURL(for: clip) { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .richText: if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .color: let hex = clip.colorHex ?? "#000000" @@ -75,18 +75,18 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource case .text: let text = clip.text ?? clip.displayTitle if let url = write(Data(textPreviewHTML(for: text).utf8), named: "Text", extension: "html") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } case .link: let text = clip.text ?? clip.displayTitle if let url = write(Data(textPreviewHTML(for: text, isLink: true).utf8), named: "Link", extension: "html") { - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } } let text = clip.text ?? clip.displayTitle guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } - return [PreviewItem(url: url, title: clip.type.label)] + return [PreviewItem(url: url, title: clip.presentationType.label)] } private func prepareTemporaryDirectory() { From b441dadf6d262399ff35b103696c389baf364796 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 31 Jul 2026 17:41:31 -0700 Subject: [PATCH 6/6] Adapt link cards to compact Paste Bars --- Sources/Pesty/UI/ClipCardView.swift | 3 +- Sources/Pesty/UI/ClipPreviewViews.swift | 74 ++++++++++++++++++++----- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 939bcc1..b0d3862 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -118,7 +118,8 @@ struct ClipCardView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } case .link: - LinkCardPreview(text: item.text ?? item.displayTitle) + LinkCardPreview(text: item.text ?? item.displayTitle, + titleOverride: item.customTitle) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) default: Text(item.text ?? "") diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index 9a60cda..5a916e4 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -81,13 +81,37 @@ struct LinkPreviewContent: View { struct LinkCardPreview: View { let text: String + let titleOverride: String? private let previews = LinkPreviewStore.shared + init(text: String, titleOverride: String? = nil) { + self.text = text + self.titleOverride = titleOverride + } + 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 } + private var title: String { + if let titleOverride = titleOverride?.trimmingCharacters(in: .whitespacesAndNewlines), + !titleOverride.isEmpty { + return titleOverride + } + return preview?.title ?? host + } var body: some View { + // A fixed hero image makes a link card overflow a short Paste Bar. + // Render its title and favicon instead when the rich layout cannot fit. + ViewThatFits(in: .vertical) { + richPreview + compactPreview + } + .frame(maxHeight: .infinity, alignment: .top) + .onAppear { previews.load(for: url) } + } + + private var richPreview: some View { VStack(alignment: .leading, spacing: 8) { Group { if let image = preview?.image { @@ -110,25 +134,47 @@ struct LinkCardPreview: View { .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) HStack(spacing: 7) { - if let icon = preview?.icon { - Image(nsImage: icon) - .resizable() - .interpolation(.high) - .frame(width: 16, height: 16) - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) - } else { - Image(systemName: "link") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 16, height: 16) - } - Text(preview?.title ?? host) + previewIcon(size: 16, cornerRadius: 4) + Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.textPrimary) .lineLimit(2) } } - .onAppear { previews.load(for: url) } + } + + private var compactPreview: some View { + HStack(spacing: 9) { + previewIcon(size: 36, cornerRadius: 9) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func previewIcon(size: CGFloat, cornerRadius: CGFloat) -> some View { + if let icon = preview?.icon { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .frame(width: size, height: size) + .overlay { + Image(systemName: "link") + .font(.system(size: size * 0.44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } } }