Skip to content
Merged
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
41 changes: 21 additions & 20 deletions Clipy/Sources/Managers/MenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1569,24 +1569,16 @@ final class BoardManHeaderSegmentedControl: NSSegmentedControl {
}
}

final class BoardManSearchFieldCell: NSSearchFieldCell {
private func verticallyCentered(_ contentRect: NSRect, in bounds: NSRect) -> NSRect {
guard contentRect.height > 0 else { return contentRect }
var centered = contentRect
centered.origin.y = floor(bounds.midY - (contentRect.height / 2))
return centered
}

override func searchTextRect(forBounds rect: NSRect) -> NSRect {
return verticallyCentered(super.searchTextRect(forBounds: rect), in: rect)
}

override func searchButtonRect(forBounds rect: NSRect) -> NSRect {
return verticallyCentered(super.searchButtonRect(forBounds: rect), in: rect)
}

override func cancelButtonRect(forBounds rect: NSRect) -> NSRect {
return verticallyCentered(super.cancelButtonRect(forBounds: rect), in: rect)
final class BoardManCenteredTextFieldCell: NSTextFieldCell {
override func drawingRect(forBounds rect: NSRect) -> NSRect {
var drawingRect = super.drawingRect(forBounds: rect)
guard let font else { return drawingRect }
let textHeight = ceil(font.ascender - font.descender + font.leading)
if textHeight < drawingRect.height {
drawingRect.origin.y = floor(rect.midY - (textHeight / 2))
drawingRect.size.height = textHeight
}
return drawingRect
}
}

Expand Down Expand Up @@ -1620,6 +1612,7 @@ final class BoardManHistoryCellView: NSTableCellView {
metadataLabel.drawsBackground = false
metadataLabel.font = NSFont.systemFont(ofSize: 11.5, weight: .regular)

countBadge.cell = BoardManCenteredTextFieldCell(textCell: "")
countBadge.alignment = .center
countBadge.lineBreakMode = .byTruncatingTail
countBadge.maximumNumberOfLines = 1
Expand Down Expand Up @@ -2270,7 +2263,6 @@ class BoardManPanel: NSPanel {
setupGlassBackgroundIfNeeded()

let search = NSSearchField(frame: .zero)
search.cell = BoardManSearchFieldCell(textCell: "")
search.placeholderString = "Search clipboard history and snippets"
search.target = self
search.action = #selector(searchTextChanged(_:))
Expand All @@ -2279,6 +2271,9 @@ class BoardManPanel: NSPanel {
search.focusRingType = .none
search.controlSize = .large
search.font = NSFont.systemFont(ofSize: 14, weight: .regular)
search.isEditable = true
search.isSelectable = true
search.isEnabled = true
contentView.addSubview(search)
searchField = search

Expand Down Expand Up @@ -3203,7 +3198,13 @@ class BoardManPanel: NSPanel {
let rightWidth = max(0, width - tabsWidth - headerGap)
let searchWidth = max(isCompact ? 130 : 170,
rightWidth - snippetButtonsWidth - (showsSnippetButtons ? headerGap : 0))
let searchFrame = NSIntegralRect(NSRect(x: rightX, y: headerY, width: searchWidth, height: 36))
let searchHeight = min(32, max(28, ceil(searchField?.intrinsicContentSize.height ?? 30)))
let searchFrame = NSIntegralRect(NSRect(
x: rightX,
y: floor(tabsFrame.midY - (searchHeight / 2)),
width: searchWidth,
height: searchHeight
))
searchField?.frame = searchFrame
snippetAddButton?.isHidden = !showsSnippetButtons
snippetEditButton?.isHidden = !showsSnippetButtons
Expand Down
30 changes: 20 additions & 10 deletions Clipy/Sources/Services/PasteCountInputService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class PasteCountInputService {
private var isMonitoringStarted = false
private var isStartingEventTap = false
private var suppressUntil = Date.distantPast
private var lastCountedText: String?
private var lastCountedIdentity: String?
private var lastCountedAt = Date.distantPast
private var lastDetectedAt = Date.distantPast
private let debounceInterval: TimeInterval = 0.45
Expand Down Expand Up @@ -407,32 +407,42 @@ final class PasteCountInputService {
}

private func countCurrentClipboardIfNeeded(source: String) {
guard let pastedText = NSPasteboard.general.string(forType: .string)
?? NSPasteboard.general.string(forType: .deprecatedString),
!pastedText.isEmpty else {
log("matched clip key=no reason=empty_clipboard source=\(source)")
let pasteboard = NSPasteboard.general
let text = pasteboard.string(forType: .string)
?? pasteboard.string(forType: .deprecatedString)
let image = pasteboard.readObjects(forClasses: [NSImage.self], options: nil)?.first as? NSImage

let identity: String
let lookupKey: () -> String?
if let text, !text.isEmpty {
identity = "text:\(text)"
lookupKey = { PasteCountStore.shared.keyForLatestClip(matching: text) }
} else if let image,
let fingerprint = PasteCountStore.imageFingerprint(for: image) {
identity = "image:\(fingerprint)"
lookupKey = { PasteCountStore.shared.keyForLatestImageClip(matching: image) }
} else {
log("matched clip key=no reason=unsupported_clipboard source=\(source)")
return
}

let now = Date()
if pastedText == lastCountedText,
if identity == lastCountedIdentity,
now.timeIntervalSince(lastCountedAt) < debounceInterval {
log("matched clip key=no reason=debounced source=\(source)")
return
}

DispatchQueue.global(qos: .utility).async { [weak self] in
guard let key = PasteCountStore.shared.keyForLatestClip(matching: pastedText) else {
guard let key = lookupKey() else {
self?.log("matched clip key=no source=\(source)")
return
}

DispatchQueue.main.async { [weak self] in
PasteCountStore.shared.increment(forKey: key)

self?.lastCountedText = pastedText
self?.lastCountedIdentity = identity
self?.lastCountedAt = now

self?.log("count increment success source=\(source) key=\(key)")
}
}
Expand Down
47 changes: 37 additions & 10 deletions Clipy/Sources/Services/PasteCountStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,36 @@ final class PasteCountStore {
return nil
}

let pasteCountKey = key(for: clip)
let unixTime = Int(Date().timeIntervalSince1970)
do {
try realm.write {
clip.updateTime = unixTime
return markUsedAndReturnKey(for: clip, in: realm)
}

func keyForLatestImageClip(matching image: NSImage) -> String? {
guard let targetFingerprint = Self.imageFingerprint(for: image) else { return nil }
let realm = try! Realm()
let candidates = realm.objects(CPYClip.self)
.sorted(byKeyPath: #keyPath(CPYClip.updateTime), ascending: false)
.filter { self.isImageClip($0) }
.prefix(120)

for clip in candidates {
guard !clip.dataPath.isEmpty,
let archivedData = NSKeyedUnarchiver.unarchiveObject(withFile: clip.dataPath) as? CPYClipData,
let archivedImage = archivedData.image,
Self.imageFingerprint(for: archivedImage) == targetFingerprint else {
continue
}
} catch {
return nil
return markUsedAndReturnKey(for: clip, in: realm)
}
return nil
}

postPasteCountDidChange()
return pasteCountKey
static func imageFingerprint(for image: NSImage) -> String? {
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else {
return nil
}
return "\(bitmap.pixelsWide)x\(bitmap.pixelsHigh):\(stableHash(pngData))"
}

@discardableResult
Expand Down Expand Up @@ -121,6 +139,11 @@ final class PasteCountStore {
return defaults.dictionary(forKey: Constants.UserDefaults.pasteCounts) as? [String: NSNumber] ?? [:]
}

private func markUsedAndReturnKey(for clip: CPYClip, in realm: Realm) -> String? {
let pasteCountKey = key(for: clip)
return markUsed(clip: clip, in: realm) ? pasteCountKey : nil
}

private func latestTextClip(in realm: Realm, matching string: String) -> CPYClip? {
// Manual Cmd+V count must stay fast: use Realm metadata only.
// Reading every archived CPYClipData file blocks UI on large histories.
Expand Down Expand Up @@ -154,8 +177,12 @@ final class PasteCountStore {
}

private func stableHash(_ string: String) -> String {
return Self.stableHash(Data(string.utf8))
}

private static func stableHash(_ data: Data) -> String {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in string.utf8 {
for byte in data {
hash ^= UInt64(byte)
hash = hash &* 1_099_511_628_211
}
Expand Down
53 changes: 41 additions & 12 deletions ClipyTests/EntitlementGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,31 @@ struct PasteCountInputServiceTests {
listenEventAccess: false
) == nil)
}

@Test
func imageFingerprintSurvivesArchiveRoundTripAndDistinguishesPixels() throws {
let firstImage = testImage(color: .systemRed)
let secondImage = testImage(color: .systemBlue)
let encoded = try NSKeyedArchiver.archivedData(
withRootObject: CPYClipData(image: firstImage),
requiringSecureCoding: false
)
let decodedObject = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encoded)
let decodedData = try #require(decodedObject as? CPYClipData)
let decodedImage = try #require(decodedData.image)

#expect(PasteCountStore.imageFingerprint(for: firstImage) == PasteCountStore.imageFingerprint(for: decodedImage))
#expect(PasteCountStore.imageFingerprint(for: firstImage) != PasteCountStore.imageFingerprint(for: secondImage))
}

private func testImage(color: NSColor) -> NSImage {
let image = NSImage(size: NSSize(width: 12, height: 12))
image.lockFocus()
color.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: 12, height: 12)).fill()
image.unlockFocus()
return image
}
}

@MainActor @Suite(.serialized)
Expand Down Expand Up @@ -515,21 +540,18 @@ final class BoardManPanelLayoutTests {
let search = descendants.compactMap { $0 as? NSSearchField }.first
#expect((search != nil) == expectsSearch || search?.isHidden == !expectsSearch)
if expectsSearch, let search {
guard let searchCell = search.cell as? BoardManSearchFieldCell else {
Issue.record("Search field is not using the vertically centered Board-Man search cell.")
return
}
#expect(search.cell is NSSearchFieldCell,
"Search field is not using the native interactive AppKit search cell.")
#expect((search.layer?.borderWidth ?? 0) == 0,
"Search field has a second custom layer border.")
#expect(abs(search.frame.midY - tabs.frame.midY) <= 0.5,
"Search input is not vertically centered with the header tabs.")
#expect(search.frame.height == tabs.frame.height)
let textRect = searchCell.searchTextRect(forBounds: search.bounds)
let buttonRect = searchCell.searchButtonRect(forBounds: search.bounds)
#expect(abs(textRect.midY - search.bounds.midY) <= 0.5,
"Search text is not vertically centered inside its border.")
#expect(abs(buttonRect.midY - search.bounds.midY) <= 0.5,
"Search icon is not vertically centered inside its border.")
"Search control is not vertically centered with the header tabs.")
#expect(search.frame.height >= 28 && search.frame.height <= 32,
"Search control is stretched beyond its native interactive height.")
#expect(search.isEditable && search.isSelectable && search.isEnabled,
"Search control cannot receive text input.")
#expect(search.target != nil && search.action != nil,
"Search control is missing its input action wiring.")
}
}

Expand All @@ -551,6 +573,13 @@ final class BoardManPanelLayoutTests {
"Usage badge is not vertically centered in the row.")
#expect(badgeFrame.width >= 38,
"Usage badge is too narrow and may clip its text.")

let badgeCell = BoardManCenteredTextFieldCell(textCell: "×567")
badgeCell.font = NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .medium)
let badgeBounds = NSRect(x: 0, y: 0, width: 48, height: 20)
let textRect = badgeCell.drawingRect(forBounds: badgeBounds)
#expect(abs(textRect.midY - badgeBounds.midY) <= 0.5,
"Usage count text is not vertically centered inside its badge.")
}

private func allSubviews(of view: NSView) -> [NSView] {
Expand Down
Loading