diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..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? @@ -137,6 +138,8 @@ final class AppController: NSObject, NSApplicationDelegate { store.searchText = "" store.source = .history store.selectFirst() + store.inlinePreviewVisible = false + inlinePreviewController?.hide() if barController == nil { barController = BarWindowController() @@ -147,9 +150,37 @@ final class AppController: NSObject, NSApplicationDelegate { func hideBar() { stopKeyMonitor() + store.inlinePreviewVisible = false + inlinePreviewController?.hide() barController?.hide() } + func toggleInlinePreview() { + guard Settings.shared.clipPreviewStyle == .inlinePesty, + store.selectedItem != nil else { return } + if store.inlinePreviewVisible { + hideInlinePreview() + } else { + store.inlinePreviewVisible = true + } + } + + func hideInlinePreview() { + guard store.inlinePreviewVisible else { return } + store.inlinePreviewVisible = 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() { guard let item = store.selectedItem else { return } hideBar() @@ -211,6 +242,13 @@ final class AppController: NSObject, NSApplicationDelegate { } switch code { + case kVK_Space where store.searchText.isEmpty: + if Settings.shared.clipPreviewStyle == .nativeQuickLook { + QuickLookService.shared.toggle(items: store.visibleItems, selectedID: store.selectedID) + } else { + toggleInlinePreview() + } + return nil case kVK_Escape: if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() } else { hideBar() } @@ -218,9 +256,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 +282,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/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/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..922b918 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -3,17 +3,30 @@ 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 strip } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .coordinateSpace(name: "PestyBar") + .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() @@ -26,12 +39,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 { AppController.shared.toggleInlinePreview() } label: { + Image(systemName: store.inlinePreviewVisible ? "rectangle.on.rectangle" : "rectangle.on.rectangle.angled") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(store.inlinePreviewVisible ? Theme.selection : Theme.textSecondary) + .frame(width: 30, height: 30) + } + .buttonStyle(.plain) + .help(store.inlinePreviewVisible ? "Hide clip preview" : "Show clip preview") + } + private var syncButton: some View { Button { AppController.shared.toggleICloudSync() @@ -95,6 +120,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)) @@ -128,6 +160,22 @@ 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 { + 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 { diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index cbd1c2a..f055ef6 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -70,6 +70,14 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { guard !isPresenting, !AppController.shared.suppressAutoHide else { return } - AppController.shared.hideBar() + // 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, + !ClipboardStore.shared.inlinePreviewVisible else { return } + AppController.shared.hideBar() + } } } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..b0d3862 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,22 +101,26 @@ 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: - 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, + titleOverride: item.customTitle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) default: Text(item.text ?? "") .font(.system(size: 12.5)) @@ -130,11 +138,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)) @@ -163,7 +166,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 new file mode 100644 index 0000000..5a916e4 --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -0,0 +1,442 @@ +import AppKit +import SwiftUI +import WebKit + +struct RichTextContent: View { + let rtfData: Data? + let fallback: String + var font: Font = .system(size: 13) + var lineLimit: Int? = nil + + var body: some View { + Group { + if let richText { + Text(richText) + } else { + Text(fallback) + } + } + .font(font) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + } + + private var richText: AttributedString? { + guard let rtfData, + let value = try? NSAttributedString(data: rtfData, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil) else { return nil } + return AttributedString(value) + } +} + +struct LinkPreviewContent: View { + let text: String + let compact: Bool + private let previews = LinkPreviewStore.shared + + private var url: URL? { URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } + private var preview: LinkPreview? { previews.preview(for: url) } + private var host: String { url?.host ?? text } + + var body: some View { + HStack(spacing: compact ? 8 : 12) { + icon + VStack(alignment: .leading, spacing: compact ? 2 : 5) { + Text(preview?.title ?? host) + .font(.system(size: compact ? 12 : 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(compact ? 2 : 3) + Text(host) + .font(.system(size: compact ? 10 : 12)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .onAppear { previews.load(for: url) } + } + + @ViewBuilder + private var icon: some View { + if let image = preview?.icon { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .clipShape(RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: compact ? 6 : 10, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + .frame(width: compact ? 28 : 42, height: compact ? 28 : 42) + .overlay { + Image(systemName: "link") + .font(.system(size: compact ? 12 : 17, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + } +} + +struct LinkCardPreview: View { + let text: String + 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 { + 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) { + previewIcon(size: 16, cornerRadius: 4) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + } + } + } + + 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) + } + } + } +} + +struct PestyPreviewPopover: View { + let item: ClipItem + 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 { + 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 + } + } + .allowsHitTesting(true) + } + + private var previewPanel: some View { + VStack(spacing: 0) { + HStack(spacing: 11) { + Button { AppController.shared.hideInlinePreview() } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + 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, 20) + .frame(height: 56) + + Divider().overlay(chromeBorder) + + Group { + if let url { + WebLinkPreview(url: url) + } else { + SelectedClipPreviewView(item: item) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(documentColor, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .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) + } +} + +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 } + + var body: some View { + previewContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @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) + .padding(14) + } else { missingPreview("photo") } + case .richText: + ScrollView { + 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: + LinkPreviewContent(text: item.text ?? item.displayTitle, compact: false) + .padding(28) + case .file: + if let image = filePreviewImage { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(14) + } else { + VStack(spacing: 14) { + Image(systemName: "doc.fill") + .font(.system(size: 52, weight: .light)) + .foregroundStyle(item.type.accent) + Text(item.displayTitle) + .font(.system(size: 18, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundStyle(Color.primary) + } + .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: 26, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .shadow(radius: 2) + } + .padding(22) + case .text: + ScrollView { + Text(item.text ?? "") + .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: 44, weight: .light)) + .foregroundStyle(.tertiary) + .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/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/LinkPreviewStore.swift b/Sources/Pesty/Util/LinkPreviewStore.swift new file mode 100644 index 0000000..7bfb1bb --- /dev/null +++ b/Sources/Pesty/Util/LinkPreviewStore.swift @@ -0,0 +1,95 @@ +import AppKit +import Foundation +import Observation + +struct LinkPreview { + var title: String? + var icon: NSImage? + var image: NSImage? +} + +@Observable +@MainActor +final class LinkPreviewStore { + static let shared = LinkPreviewStore() + + private var previews: [String: LinkPreview] = [:] + private var loadingHosts: Set = [] + + private init() {} + + func preview(for url: URL?) -> LinkPreview? { + guard let host = url?.host?.lowercased() else { return nil } + return previews[host] + } + + func load(for url: URL?) { + guard let url, + let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), + let host = url.host?.lowercased(), + !loadingHosts.contains(host) else { return } + loadingHosts.insert(host) + previews[host] = previews[host] ?? LinkPreview() + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.setValue("Pesty/1.0", forHTTPHeaderField: "User-Agent") + URLSession.shared.dataTask(with: request) { data, _, _ in + let metadata = data.flatMap { Self.pageMetadata(from: $0, relativeTo: url) } + DispatchQueue.main.async { + self.update(host: host, title: metadata?.title, icon: nil, image: nil, finished: false) + } + if let imageURL = metadata?.imageURL { + URLSession.shared.dataTask(with: imageURL) { imageData, _, _ in + let image = imageData.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: nil, image: image, finished: false) + } + }.resume() + } + }.resume() + + var faviconURL = URLComponents() + faviconURL.scheme = scheme + faviconURL.host = host + faviconURL.path = "/favicon.ico" + guard let iconURL = faviconURL.url else { + loadingHosts.remove(host) + return + } + URLSession.shared.dataTask(with: iconURL) { data, _, _ in + let icon = data.flatMap(NSImage.init(data:)) + DispatchQueue.main.async { + self.update(host: host, title: nil, icon: icon, image: nil, finished: true) + } + }.resume() + } + + private func update(host: String, title: String?, icon: NSImage?, image: NSImage?, finished: Bool) { + var preview = previews[host] ?? LinkPreview() + if let title, !title.isEmpty { preview.title = title } + if let icon { preview.icon = icon } + if let image { preview.image = image } + previews[host] = preview + if finished { loadingHosts.remove(host) } + } + + nonisolated private static func pageMetadata(from data: Data, relativeTo url: URL) -> (title: String?, imageURL: URL?)? { + guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1), + let expression = try? NSRegularExpression(pattern: "]*>(.*?)", + options: [.caseInsensitive, .dotMatchesLineSeparators]), + let match = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let range = Range(match.range(at: 1), in: html) else { return nil } + let title = html[range] + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + let imagePattern = "]+(?:property|name)=[\\\"'](?:og:image|twitter:image)[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']" + let imageExpression = try? NSRegularExpression(pattern: imagePattern, options: [.caseInsensitive]) + let imageURL: URL? = imageExpression.flatMap { expression in + guard let imageMatch = expression.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let imageRange = Range(imageMatch.range(at: 1), in: html) else { return nil } + return URL(string: String(html[imageRange]), relativeTo: url)?.absoluteURL + } + return (title.isEmpty ? nil : title, imageURL) + } +} diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift new file mode 100644 index 0000000..0d8632f --- /dev/null +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -0,0 +1,157 @@ +import AppKit +@preconcurrency import QuickLookUI + +@MainActor +final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = QuickLookService() + + private var previewItems: [PreviewItem] = [] + private var startIndexByClipID: [UUID: Int] = [:] + private let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("Pesty-QuickLook", isDirectory: true) + + private override init() {} + + var isVisible: Bool { QLPreviewPanel.shared()?.isVisible ?? false } + + func toggle(items: [ClipItem], selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared() else { return } + if panel.isVisible { + panel.orderOut(nil) + return + } + + prepareTemporaryDirectory() + var selectedIndex = 0 + var newItems: [PreviewItem] = [] + var newStartIndexes: [UUID: Int] = [:] + for clip in items { + let startIndex = newItems.count + newItems.append(contentsOf: previewItems(for: clip)) + if startIndex < newItems.count { newStartIndexes[clip.id] = startIndex } + if clip.id == selectedID, startIndex < newItems.count { selectedIndex = startIndex } + } + guard !newItems.isEmpty else { return } + + previewItems = newItems + startIndexByClipID = newStartIndexes + panel.dataSource = self + panel.reloadData() + panel.currentPreviewItemIndex = selectedIndex + panel.makeKeyAndOrderFront(nil) + } + + func updateSelection(selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared(), panel.isVisible, + let selectedID, let index = startIndexByClipID[selectedID] else { return } + panel.currentPreviewItemIndex = index + } + + func numberOfPreviewItems(in panel: QLPreviewPanel) -> Int { previewItems.count } + + func previewPanel(_ panel: QLPreviewPanel, previewItemAt index: Int) -> QLPreviewItem { + previewItems[index] + } + + private func previewItems(for clip: ClipItem) -> [PreviewItem] { + switch clip.type { + case .file: + let files = clip.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) + if !files.isEmpty { return files.map { PreviewItem(url: $0, title: clip.presentationType.label) } } + case .image: + if let url = ClipboardStore.shared.imageURL(for: clip) { + return [PreviewItem(url: url, title: clip.presentationType.label)] + } + case .richText: + if let data = clip.rtfData, let url = write(data, named: clip.displayTitle, extension: "rtf") { + return [PreviewItem(url: url, title: clip.presentationType.label)] + } + case .color: + let hex = clip.colorHex ?? "#000000" + let html = "\(hex)" + if let url = write(Data(html.utf8), named: "Color \(hex)", extension: "html") { + return [PreviewItem(url: url, title: hex)] + } + case .text: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text).utf8), named: "Text", extension: "html") { + return [PreviewItem(url: url, title: clip.presentationType.label)] + } + case .link: + let text = clip.text ?? clip.displayTitle + if let url = write(Data(textPreviewHTML(for: text, isLink: true).utf8), named: "Link", extension: "html") { + return [PreviewItem(url: url, title: clip.presentationType.label)] + } + } + + let text = clip.text ?? clip.displayTitle + guard let url = write(Data(text.utf8), named: clip.displayTitle, extension: "txt") else { return [] } + return [PreviewItem(url: url, title: clip.presentationType.label)] + } + + private func prepareTemporaryDirectory() { + try? FileManager.default.removeItem(at: temporaryDirectory) + try? FileManager.default.createDirectory(at: temporaryDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + private func write(_ data: Data, named title: String, extension fileExtension: String) -> URL? { + let safeTitle = title.replacingOccurrences(of: "/", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseName = safeTitle.isEmpty ? "Clip" : String(safeTitle.prefix(80)) + let filename = "\(baseName)-\(UUID().uuidString).\(fileExtension)" + let url = temporaryDirectory.appendingPathComponent(filename) + do { + try data.write(to: url, options: .atomic) + return url + } catch { return nil } + } + + /// Use a local, self-contained document so Quick Look can present text and + /// links more readably without loading remote content or running scripts. + private func textPreviewHTML(for text: String, isLink: Bool = false) -> String { + let characterCount = text.count + let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count + let lineCount = max(1, text.split(separator: "\n", omittingEmptySubsequences: false).count) + let escaped = escapeHTML(text) + let content = isLink ? "
\(escaped)
" : "
\(escaped)
" + let words = wordCount == 1 ? "word" : "words" + let lines = lineCount == 1 ? "line" : "lines" + + return """ + + +
\(content)
+ + + """ + } + + private func escapeHTML(_ text: String) -> String { + text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } +} + +private final class PreviewItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(url: URL, title: String) { + previewItemURL = url + previewItemTitle = title + } +}