diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..bfa5b32 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -82,6 +82,7 @@ final class Settings { static let ignoreConcealed = "ignoreConcealed" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" + static let pasteStyleCards = "pasteStyleCards" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -174,6 +175,10 @@ final class Settings { } } + var pasteStyleCards: Bool { + didSet { guard isLoaded else { return }; d.set(pasteStyleCards, forKey: Keys.pasteStyleCards) } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -210,6 +215,7 @@ final class Settings { Keys.ignoreConcealed: true, Keys.ignoredSourceAppBundleIDs: [], Keys.barHeight: 430.0, + Keys.pasteStyleCards: true, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, @@ -231,6 +237,7 @@ final class Settings { ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) .filter { !$0.isEmpty } barHeight = d.double(forKey: Keys.barHeight) + pasteStyleCards = d.bool(forKey: Keys.pasteStyleCards) showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..1a35328 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -136,6 +136,7 @@ private struct GeneralSettings: View { Toggle("Hide Pesty when clicking outside", isOn: $settings.hideOnClickOutside) Toggle("Launch at login", isOn: $settings.launchAtLogin) Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) + Toggle("Paste-style clip cards", isOn: $settings.pasteStyleCards) VStack(alignment: .leading) { LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index a8b4725..bd464c1 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,5 +1,7 @@ import AppKit +import QuickLookThumbnailing import SwiftUI +import UniformTypeIdentifiers struct ClipCardView: View { let item: ClipItem @@ -7,6 +9,8 @@ struct ClipCardView: View { let selected: Bool @State private var hovering = false + @State private var fileThumbnail: NSImage? + @State private var fileIsMissing = false private var store: ClipboardStore { ClipboardStore.shared } private var settings: Settings { Settings.shared } private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } @@ -46,6 +50,7 @@ struct ClipCardView: View { .highPriorityGesture(TapGesture().modifiers(.command).onEnded { store.toggleSelection(item.id) }) .onDrag { ClipDragProvider.make(for: item) } .contextMenu { menu } + .task(id: item.id) { await loadFileThumbnail() } } private var header: some View { @@ -53,7 +58,7 @@ struct ClipCardView: View { headerColor HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 2) { - Text(item.type.label) + Text(cardTypeLabel) .font(.system(size: 15, weight: .bold)) .foregroundStyle(Theme.headerText) Text(item.createdAt.clipRelativeLong) @@ -62,12 +67,49 @@ struct ClipCardView: View { } .lineLimit(1) Spacer(minLength: 4) - appIconTile + if settings.pasteStyleCards { + // The enlarged icon is an overlay so its overhang can't + // stretch the header; this only reserves the width its + // visible part covers, keeping the title clear of it. + Color.clear.frame(width: enlargedIconReservedWidth, height: 1) + } else { + appIconTile + } } .padding(.horizontal, 13) - .padding(.vertical, 7) + .padding(.vertical, settings.pasteStyleCards ? 5 : 7) + } + .frame(height: settings.pasteStyleCards ? Theme.enlargedHeaderHeight : Theme.headerHeight) + .overlay(alignment: .topTrailing) { + if settings.pasteStyleCards { enlargedAppIcon } + } + } + + /// A multi-file clip is one clip of many files, so the count is the + /// headline - naming only the first file hid the other four. + private var cardTypeLabel: String { + guard settings.pasteStyleCards, item.type == .file, item.fileURLs.count > 1 else { + return item.type.label } - .frame(height: Theme.headerHeight) + return "\(item.fileURLs.count) files" + } + + /// Scaled past the card's top and trailing edges, then cropped by the + /// card's own rounded rectangle — the icon frames the corner instead of + /// sitting fully inside a tile. + private var enlargedAppIcon: some View { + let icon = AppIconProvider.trimmedIcon(forBundleID: item.sourceBundleID) + let aspect = icon.size.height > 0 ? icon.size.width / icon.size.height : 1 + return Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: Theme.enlargedIconSize * aspect, height: Theme.enlargedIconSize) + .offset(x: Theme.enlargedIconOverhang, y: -Theme.enlargedIconRise) + .allowsHitTesting(false) + } + + private var enlargedIconReservedWidth: CGFloat { + Theme.enlargedIconSize - Theme.enlargedIconOverhang - 13 } private var appIconTile: some View { @@ -88,17 +130,64 @@ struct ClipCardView: View { private var body_: some View { VStack(alignment: .leading, spacing: 0) { - content - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + if showsFullBleedImage { + imageCanvas + } else { + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 13) + .padding(.top, 11) + } footer + .padding(.horizontal, 13) + .padding(.bottom, 10) } - .padding(.horizontal, 13) - .padding(.top, 11) - .padding(.bottom, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Theme.cardBody) } + /// Image clips run edge to edge instead of sitting inset in the card body: + /// a padded thumbnail wastes the card's whole point, which is recognizing + /// the picture at a glance. A copied screenshot arrives as a *file* rather + /// than image data, so file clips pointing at an image get the same + /// treatment - the distinction is invisible to the person who copied it. + private var showsFullBleedImage: Bool { + settings.pasteStyleCards && (item.type == .image || singleImageFileURL != nil) + } + + /// Matched on the path extension rather than by loading the file: this is + /// evaluated on every card render, so it must not touch disk. + private var singleImageFileURL: URL? { + guard item.type == .file, + item.fileURLs.count == 1, + let url = item.fileURLs.first.flatMap(URL.init(string:)), + url.isFileURL, + let type = UTType(filenameExtension: url.pathExtension), + type.conforms(to: .image) else { return nil } + return url + } + + private var fullBleedImage: NSImage? { + if item.type == .image { return store.loadImage(for: item) } + return singleImageFileURL.flatMap(NSImage.init(contentsOf:)) + } + + private var imageCanvas: some View { + ZStack { + // Transparency has to be visible, not guessed at — an image with a + // cut-out is otherwise indistinguishable from one on white. + CheckerboardBackground() + if let img = fullBleedImage { + Image(nsImage: img) + .resizable().interpolation(.medium).scaledToFit() + } else { + placeholder("photo") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + @ViewBuilder private var content: some View { switch item.type { @@ -117,14 +206,7 @@ struct ClipCardView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) case .file: - VStack(spacing: 9) { - Image(systemName: "doc.fill").font(.system(size: 32)) - .foregroundStyle(headerColor) - Text(item.displayTitle).font(.system(size: 12)) - .foregroundStyle(Theme.cardTextSecondary).lineLimit(2) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + fileContent case .link: VStack(spacing: 10) { Spacer(minLength: 0) @@ -142,6 +224,113 @@ struct ClipCardView: View { } } + /// Paste-style file cards are carried by the file's own contents, then by + /// its icon; with the toggle off the card keeps the small generic glyph it + /// has always had. The missing-file state is deliberately not gated on the + /// toggle: it reports a fact about the clip rather than styling it. + @ViewBuilder + private var fileContent: some View { + if settings.pasteStyleCards, item.fileURLs.count > 1 { + stackedFileIcons + } else if settings.pasteStyleCards, let thumbnail = fileThumbnail { + // A real preview of the contents, the way Quick Look renders it: + // the type icon says which app opens the file, not what is in it. + Image(nsImage: thumbnail) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .shadow(color: .black.opacity(0.14), radius: 3, y: 1) + } else { + VStack(spacing: settings.pasteStyleCards ? 10 : 9) { + fileIcon + .opacity(fileIsMissing ? 0.5 : 1) + Text(item.displayTitle).font(.system(size: 12)) + .foregroundStyle(Theme.cardTextSecondary).lineLimit(2) + .multilineTextAlignment(.center) + if fileIsMissing { + // Without this the card looks like a failed render rather + // than what it is: a clip whose file has moved or gone. + Text("File not found") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.cardTextTertiary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var fileIcon: some View { + // The file's real icon, at a size worth looking at: the type badge is + // the whole identity of a file that has no preview. + if settings.pasteStyleCards, let icon = workspaceFileIcon { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: Theme.fileIconSize, height: Theme.fileIconSize) + } else { + Image(systemName: "doc.fill") + .font(.system(size: settings.pasteStyleCards ? Theme.fileIconSize * 0.6 : 32)) + .foregroundStyle(headerColor) + } + } + + /// The real file's icon when it is still there, and the icon for its type + /// when it is not — asking the workspace about a path that no longer + /// exists yields a blank page, which looks like a bug. nil when the clip + /// carries no usable file URL, which falls the card back to the glyph. + private var workspaceFileIcon: NSImage? { + guard let url = item.fileURLs.first.flatMap(URL.init(string:)), url.isFileURL else { + return nil + } + if !fileIsMissing { return NSWorkspace.shared.icon(forFile: url.path) } + return NSWorkspace.shared.icon(for: UTType(filenameExtension: url.pathExtension) ?? .data) + } + + /// Thumbnails are generated off the main actor by the system and cached by + /// path, so scrolling the strip does not re-render a preview per frame. + /// Generation is not gated on the toggle even though display is: the task + /// is keyed on the clip, so a card already on screen when the toggle is + /// flipped on would otherwise sit on its icon until it was rebuilt. + private func loadFileThumbnail() async { + guard item.type == .file, + let url = item.fileURLs.first.flatMap(URL.init(string:)), + url.isFileURL else { return } + fileIsMissing = !FileManager.default.fileExists(atPath: url.path) + guard !fileIsMissing, + item.fileURLs.count == 1, + singleImageFileURL == nil else { return } + if let cached = FileThumbnailProvider.shared.cached(for: url) { + fileThumbnail = cached + return + } + fileThumbnail = await FileThumbnailProvider.shared.thumbnail( + for: url, + size: CGSize(width: Theme.cardWidth - 26, height: 190), + scale: NSScreen.main?.backingScaleFactor ?? 2) + } + + /// The real icons of the first few files, fanned out behind one another: + /// it shows both what kind of files these are and that there is more than + /// one, which a single generic page icon cannot. + private var stackedFileIcons: some View { + let urls = Array(item.fileURLs.prefix(3).compactMap { URL(string: $0) }) + return ZStack { + // Reversed so the first file lands on top of the stack. + ForEach(Array(urls.enumerated()).reversed(), id: \.offset) { index, url in + Image(nsImage: NSWorkspace.shared.icon(forFile: url.path)) + .resizable() + .interpolation(.high) + .frame(width: Theme.fileIconSize, height: Theme.fileIconSize) + .opacity(index == 0 ? 1 : 0.92) + .shadow(color: .black.opacity(0.16), radius: 3, y: 1) + .offset(x: CGFloat(index) * -12, y: CGFloat(index) * -10) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func placeholder(_ symbol: String) -> some View { Image(systemName: symbol).font(.system(size: 30)) .foregroundStyle(Theme.cardTextTertiary) @@ -308,3 +497,28 @@ struct ClipCardView: View { return image } } + +/// The standard transparency checkerboard drawn behind image clips. Canvas +/// rather than a tiled Image: the pattern is a handful of rects, and this +/// keeps it resolution-independent without shipping an asset. +private struct CheckerboardBackground: View { + var square: CGFloat = 8 + + var body: some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white)) + let columns = Int(ceil(size.width / square)) + let rows = Int(ceil(size.height / square)) + for row in 0.. NSImage { + let key = bundleID ?? "__generic__" + if let cached = trimmedCache[key] { return cached } + let source = icon(forBundleID: bundleID) + let trimmed = trim(source) ?? source + trimmedCache[key] = trimmed + return trimmed + } + + private static var trimmedCache: [String: NSImage] = [:] + + private static func trim(_ image: NSImage) -> NSImage? { + // Render at 512px, not at the icon's nominal point size: NSImage picks + // the representation matching the target, so this pulls the sharpest + // artwork available and leaves headroom for Retina drawing. Cropping + // pixels out of this keeps the result crisp; re-rasterizing at point + // size would bake in a blurry upscale. + let pixels = 512 + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: pixels, pixelsHigh: pixels, + bitsPerSample: 8, samplesPerPixel: 4, + hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: pixels * 4, bitsPerPixel: 32), + let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return nil } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.imageInterpolation = .high + image.draw(in: NSRect(x: 0, y: 0, width: pixels, height: pixels)) + NSGraphicsContext.restoreGraphicsState() + + guard let data = bitmap.bitmapData else { return nil } + let bytesPerRow = bitmap.bytesPerRow + var minX = pixels, minY = pixels, maxX = -1, maxY = -1 + for y in 0.. 12 { + if x < minX { minX = x } + if x > maxX { maxX = x } + if y < minY { minY = y } + if y > maxY { maxY = y } + } + } + guard maxX >= minX, maxY >= minY, + let full = bitmap.cgImage else { return nil } + + let crop = CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1) + guard crop.width < CGFloat(pixels) || crop.height < CGFloat(pixels), + let cropped = full.cropping(to: crop) else { return nil } + // Half the pixel count as the point size, so the image carries 2x + // density wherever the card draws it. + return NSImage(cgImage: cropped, + size: NSSize(width: crop.width / 2, height: crop.height / 2)) + } + static let generic: NSImage = NSImage(systemSymbolName: "app.dashed", accessibilityDescription: nil) ?? NSImage() diff --git a/Sources/Pesty/Util/FileThumbnailProvider.swift b/Sources/Pesty/Util/FileThumbnailProvider.swift new file mode 100644 index 0000000..08b9e44 --- /dev/null +++ b/Sources/Pesty/Util/FileThumbnailProvider.swift @@ -0,0 +1,43 @@ +import AppKit +import QuickLookThumbnailing + +/// Quick Look thumbnails for file clips, so a card shows what the file *is* +/// rather than which application opens it — a folder of models or PDFs is +/// otherwise a wall of identical type icons. +@MainActor +final class FileThumbnailProvider { + static let shared = FileThumbnailProvider() + + private var cache: [String: NSImage] = [:] + /// Paths whose generation already failed, so a file the system has no + /// preview for isn't retried on every card render. + private var unavailable: Set = [] + + private init() {} + + func cached(for url: URL) -> NSImage? { cache[url.path] } + + func thumbnail(for url: URL, size: CGSize, scale: CGFloat) async -> NSImage? { + let key = url.path + if let cached = cache[key] { return cached } + guard !unavailable.contains(key) else { return nil } + + let request = QLThumbnailGenerator.Request(fileAt: url, + size: size, + scale: scale, + representationTypes: .thumbnail) + let representation: QLThumbnailRepresentation? = await withCheckedContinuation { continuation in + QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { representation, _ in + continuation.resume(returning: representation) + } + } + + guard let representation else { + unavailable.insert(key) + return nil + } + let image = representation.nsImage + cache[key] = image + return image + } +}