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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ xcuserdata/
Maccy.xcodeproj/project.xcworkspace/xcshareddata
.idea
.DS_Store
Maccy/.DS_Store
Maccy/.DS_Store
20 changes: 19 additions & 1 deletion Maccy/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {

@objc
private func performStatusItemClick() {
let now = Date()
if let lastStatusItemClickAt,
now.timeIntervalSince(lastStatusItemClickAt) < Self.statusItemClickDebounceInterval {
return
}
lastStatusItemClickAt = now

if let event = NSApp.currentEvent {
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)

Expand All @@ -154,14 +161,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

panel.toggle(height: AppState.shared.popup.height, at: .statusItem)
if panel.isPresented {
AppState.shared.popup.close()
} else {
AppState.shared.popup.open(height: AppState.shared.popup.height, at: .statusItem)
}
}

private var pendingMenuIconTextSync = false
private var lastStatusItemClickAt: Date?
private static let statusItemClickDebounceInterval: TimeInterval = 0.35

private func synchronizeMenuIconText() {
guard !pendingMenuIconTextSync else { return }
pendingMenuIconTextSync = true
_ = withObservationTracking {
AppState.shared.menuIconText
} onChange: {
DispatchQueue.main.async {
self.pendingMenuIconTextSync = false
if Defaults[.showRecentCopyInMenuBar] {
self.statusItem.button?.title = AppState.shared.menuIconText
}
Expand Down
9 changes: 9 additions & 0 deletions Maccy/ApplicationImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ class ApplicationImage {
self.image = image
}

deinit {
invalidate()
}

func invalidate() {
eventSource?.cancel()
eventSource = nil
}

var nsImage: NSImage {
guard let bundleIdentifier else {
return Self.fallbackImage
Expand Down
52 changes: 51 additions & 1 deletion Maccy/ApplicationImageCache.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,76 @@
class ApplicationImageCache {
static let shared = ApplicationImageCache()
private static let maxCachedImages = 30

private let universalClipboardIdentifier: String =
"com.apple.finder.Open-iCloudDrive"
private let fallback = ApplicationImage(bundleIdentifier: nil)
private var cache: [String: ApplicationImage] = [:]
private var recentlyUsed: [String] = []

func getImage(item: HistoryItem) -> ApplicationImage {
guard let bundleIdentifier = bundleIdentifier(for: item) else {
return fallback
}

if let image = cache[bundleIdentifier] {
markUsed(bundleIdentifier)
return image
}

let image = ApplicationImage(bundleIdentifier: bundleIdentifier)
cache[bundleIdentifier] = image
store(image, for: bundleIdentifier)

return image
}

// Fast path used during decorator init: only reads the `application`
// attribute so we don't hydrate the `contents` relationship for every item.
func getImage(bundleIdentifier: String?) -> ApplicationImage {
guard let bundleIdentifier else {
return fallback
}

if let image = cache[bundleIdentifier] {
markUsed(bundleIdentifier)
return image
}

let image = ApplicationImage(bundleIdentifier: bundleIdentifier)
store(image, for: bundleIdentifier)

return image
}

// Drop every cached `ApplicationImage`, releasing their NSImage raster
// caches and DispatchSource file watchers. Called from `History`'s idle
// flush so a closed-popup process doesn't keep app icons resident forever.
// Icons get recreated lazily next time a row asks for them.
func clear() {
cache.values.forEach { $0.invalidate() }
cache.removeAll()
recentlyUsed.removeAll()
}

private func store(_ image: ApplicationImage, for bundleIdentifier: String) {
cache[bundleIdentifier] = image
markUsed(bundleIdentifier)
pruneIfNeeded()
}

private func markUsed(_ bundleIdentifier: String) {
recentlyUsed.removeAll { $0 == bundleIdentifier }
recentlyUsed.append(bundleIdentifier)
}

private func pruneIfNeeded() {
while recentlyUsed.count > Self.maxCachedImages,
let staleBundleIdentifier = recentlyUsed.first {
recentlyUsed.removeFirst()
cache.removeValue(forKey: staleBundleIdentifier)?.invalidate()
}
}

private func bundleIdentifier(for item: HistoryItem) -> String? {
if item.universalClipboard {
return universalClipboardIdentifier
Expand Down
78 changes: 76 additions & 2 deletions Maccy/Clipboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class Clipboard {
sync()

Task {
Notifier.notify(body: item.title, sound: .knock)
Notifier.notify(body: item.title.shortened(to: 160), sound: .knock)
checkForChangesInPasteboard()
}
}
Expand Down Expand Up @@ -208,7 +208,8 @@ class Clipboard {
}

types.forEach { type in
contents.append(HistoryItemContent(type: type.rawValue, value: item.data(forType: type)))
let value = compressImageDataIfNeeded(item.data(forType: type), type: type)
contents.append(HistoryItemContent(type: type.rawValue, value: value))
}
})

Expand All @@ -225,6 +226,8 @@ class Clipboard {

historyItem.application = sourceApp?.bundleIdentifier
historyItem.title = historyItem.generateTitle()
historyItem.ensureContentFingerprint()
historyItem.ensureThumbnailData()

onNewCopyHooks.forEach({ $0(historyItem) })
}
Expand Down Expand Up @@ -299,6 +302,77 @@ class Clipboard {
NSApp.hide(self)
}

// Cap stored images so a single screenshot doesn't bloat the history store.
// Preview max is ~2048×1536; anything beyond 4096 on either side is pure waste.
private static let maxStoredImageDimension: CGFloat = 4096
private static let imagePasteboardTypes: Set<NSPasteboard.PasteboardType> = [.png, .tiff, .jpeg, .heic]

private func compressImageDataIfNeeded(
_ data: Data?,
type: NSPasteboard.PasteboardType
) -> Data? {
guard let data,
Self.imagePasteboardTypes.contains(type),
let image = NSImage(data: data) else {
return data
}

let size = image.size
let maxDim = Self.maxStoredImageDimension
guard size.width > maxDim || size.height > maxDim,
size.width > 0, size.height > 0 else {
return data
}

let ratio = min(maxDim / size.width, maxDim / size.height)
let targetPixels = NSSize(
width: (size.width * ratio).rounded(),
height: (size.height * ratio).rounded()
)

guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(targetPixels.width),
pixelsHigh: Int(targetPixels.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0
) else {
return data
}
rep.size = targetPixels

NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else {
return data
}
NSGraphicsContext.current = ctx
ctx.imageInterpolation = .high
image.draw(in: NSRect(origin: .zero, size: targetPixels))

let fileType: NSBitmapImageRep.FileType
let properties: [NSBitmapImageRep.PropertyKey: Any]
switch type {
case .png:
fileType = .png
properties = [:]
case .jpeg, .heic:
fileType = .jpeg
properties = [.compressionFactor: 0.85]
default:
fileType = .png
properties = [:]
}

return rep.representation(using: fileType, properties: properties) ?? data
}

private func clearFormatting(_ contents: [HistoryItemContent]) -> [HistoryItemContent] {
var newContents: [HistoryItemContent] = contents
let stringContents = contents.filter { NSPasteboard.PasteboardType($0.type) == .string }
Expand Down
2 changes: 1 addition & 1 deletion Maccy/Extensions/Defaults.Keys+Names.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ extension Defaults.Keys {
static let searchVisibility = Key<SearchVisibility>("searchVisibility", default: .always)
static let showSpecialSymbols = Key<Bool>("showSpecialSymbols", default: true)
static let showTitle = Key<Bool>("showTitle", default: true)
static let size = Key<Int>("historySize", default: 200)
static let size = Key<Int>("historySize", default: 3000)
static let sortBy = Key<Sorter.By>("sortBy", default: .lastCopiedAt)
static let suppressClearAlert = Key<Bool>("suppressClearAlert", default: false)
static let windowSize = Key<NSSize>("windowSize", default: NSSize(width: 450, height: 800))
Expand Down
12 changes: 8 additions & 4 deletions Maccy/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,14 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
}

func open(height: CGFloat, at popupPosition: PopupPosition = Defaults[.popupPosition]) {
if AppState.shared.navigator.leadHistoryItem == nil && !AppState.shared.navigator.pasteStackSelected {
AppState.shared.preview.closeImmediately()
}

let size = Defaults[.windowSize]
setContentSize(NSSize(width: min(frame.width, size.width), height: min(height, size.height)))
setFrameOrigin(popupPosition.origin(size: frame.size, statusBarButton: statusBarButton))
AppState.shared.preview.lockPlacement(window: self)
orderFrontRegardless()
makeKey()
isPresented = true
Expand All @@ -100,6 +105,7 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {

func determinePreviewPlacement() {
let preview = AppState.shared.preview
guard !preview.placementLocked else { return }
guard !preview.state.isOpen else { return }
let newSize = preview.computeSizeWithPreview(frame.size, state: .open)
preview.placement = preview.computePlacement(window: self, for: newSize)
Expand Down Expand Up @@ -179,10 +185,7 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {

func windowDidBecomeKey(_ notification: Notification) {
AppState.shared.preview.enableAutoOpen()

if AppState.shared.navigator.leadHistoryItem != nil {
AppState.shared.preview.startAutoOpen()
}
AppState.shared.preview.startAutoOpenForCurrentSelection()
}

func windowDidResignKey(_ notification: Notification) {
Expand All @@ -201,6 +204,7 @@ class FloatingPanel<Content: View>: NSPanel, NSWindowDelegate {
override func close() {
super.close()
AppState.shared.preview.state = .closed
AppState.shared.preview.unlockPlacement()
isPresented = false
statusBarButton?.isHighlighted = false
onClose()
Expand Down
Loading