Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Sources/Pesty/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions Sources/Pesty/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
248 changes: 231 additions & 17 deletions Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import AppKit
import QuickLookThumbnailing
import SwiftUI
import UniformTypeIdentifiers

struct ClipCardView: View {
let item: ClipItem
let index: Int
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) }
Expand Down Expand Up @@ -46,14 +50,15 @@ 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 {
ZStack {
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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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..<max(rows, 0) {
for column in 0..<max(columns, 0) where (row + column).isMultiple(of: 2) {
let rect = CGRect(x: CGFloat(column) * square,
y: CGFloat(row) * square,
width: square,
height: square)
context.fill(Path(rect), with: .color(Color(white: 0.87)))
}
}
}
.drawingGroup()
}
}
20 changes: 20 additions & 0 deletions Sources/Pesty/UI/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ enum Theme {
static let cornerRadius: CGFloat = 16
static let cardCorner: CGFloat = 19
static let headerHeight: CGFloat = 68
// Enlarged-icon mode: the source icon is scaled well past the header and
// cropped by the card's own top-right corner. Every app gets the same
// size and overhang, so the crop reads as deliberate framing rather than
// as each icon being clipped differently. The header is also tighter
// here than the tile layout needs — a roomy header is what makes a large
// icon read as undersized.
// The header is sized around the icon rather than the icon being blown up
// to fill the header: scaling artwork past its natural size is what makes
// it look soft.
static let enlargedHeaderHeight: CGFloat = 50
static let enlargedIconSize: CGFloat = 62
static let enlargedIconOverhang: CGFloat = 12
// How far the artwork's bottom hangs past the header seam onto the clip
// content. The icon is drawn from trimmed artwork, so this is a true edge
// offset rather than a guess at the icon's built-in transparent padding.
static let enlargedIconDrop: CGFloat = 5
/// File cards lean on the document icon to say what the clip is, so it is
/// sized as the card's subject rather than as a small adornment.
static let fileIconSize: CGFloat = 104
static let enlargedIconRise: CGFloat = enlargedIconSize - enlargedHeaderHeight - enlargedIconDrop

static let panelTint = Color.white.opacity(0.10)
static let cardBody = Color.white.opacity(0.94)
Expand Down
Loading
Loading