From e6032d83f76e3adfbb53fba29ce2d18d7ea88b3e Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Tue, 18 Aug 2026 20:44:40 -0700 Subject: [PATCH] Add paste-style clip cards Cards spend their space on chrome instead of on the clip. The header is a 68pt band with a small icon boxed in a tile, and an image clip - the one kind of card you identify purely by looking - is squeezed into whatever the padding leaves behind. Behind the new "Paste-style clip cards" toggle, the header shrinks to 50pt and the source app's icon is scaled past the card's top-right corner, cropped by the card's own rounded rectangle so it frames the corner rather than sitting inside a tile. The header is sized around the icon rather than the icon blown up to fill the header, because scaling artwork past its natural size is what makes it look soft. The icon is drawn from artwork trimmed to its opaque bounds: macOS app icons carry a transparent margin about 10% wide, so aligning the raw image to the corner would leave a gap the size of that padding. Image clips - and file clips that point at an image, which is how a copied screenshot actually arrives - now run edge to edge, over a transparency checkerboard so a cut-out is distinguishable from white. The card body's padding moved onto the content and footer so the image can reach the card edges while the footer keeps its inset. Whether a file clip is an image is decided from its path extension, never by opening it: that runs on every render. File clips with no preview get the same treatment. The 32pt generic "doc.fill" glyph becomes the file's real Finder icon at 104pt, because that badge is the whole identity of a document you can't see into; a clip with no usable file URL still falls back to the glyph. A clip of several files fans out the real icons of its first three, first on top, which says both what kind of files these are and that there is more than one - something a single page icon cannot. Its header reads "5 files" rather than the generic type label, since a multi-file clip is one clip of many files and naming only the first hid the rest. A file that does have a preview shows it. Every PDF, model, and document otherwise looked alike, so a single non-image file now renders the system's Quick Look thumbnail of its real contents, generated once per path and cached - along with the paths the system has no preview for, so a file it cannot render is not asked about again on the next card that shows it. Generation runs in a task keyed on the clip rather than on the render pass, so scrolling the strip starts no work of its own. A clip whose file has moved or been deleted dims its icon and says "File not found", and draws that icon from the file's type rather than its path: asking the workspace about a path that no longer exists hands back a blank page, which reads as a failed render. This is the one thing here that is not behind the toggle. It reports a fact about the clip rather than restyling it, and a clip that can no longer paste anything should say so in either layout. The toggle defaults on; turning it off restores the tile header, the inset image, and the generic file glyph exactly as before - save for that missing-file caption, which is information rather than style. --- Sources/Pesty/Settings/Settings.swift | 7 + Sources/Pesty/Settings/SettingsView.swift | 1 + Sources/Pesty/UI/ClipCardView.swift | 248 ++++++++++++++++-- Sources/Pesty/UI/Theme.swift | 20 ++ Sources/Pesty/Util/AppIconProvider.swift | 61 +++++ .../Pesty/Util/FileThumbnailProvider.swift | 43 +++ 6 files changed, 363 insertions(+), 17 deletions(-) create mode 100644 Sources/Pesty/Util/FileThumbnailProvider.swift 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 + } +}