From 419c17971c554ea428d6802283a6cbfc382a0d55 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:41:16 +0900 Subject: [PATCH] Fix search input and image paste counting --- Clipy/Sources/Managers/MenuManager.swift | 41 +++++++------- .../Services/PasteCountInputService.swift | 30 +++++++---- Clipy/Sources/Services/PasteCountStore.swift | 47 ++++++++++++---- ClipyTests/EntitlementGateTests.swift | 53 ++++++++++++++----- 4 files changed, 119 insertions(+), 52 deletions(-) diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index 7e2d75d..c425967 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -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 } } @@ -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 @@ -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(_:)) @@ -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 @@ -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 diff --git a/Clipy/Sources/Services/PasteCountInputService.swift b/Clipy/Sources/Services/PasteCountInputService.swift index 226b541..c4246f6 100644 --- a/Clipy/Sources/Services/PasteCountInputService.swift +++ b/Clipy/Sources/Services/PasteCountInputService.swift @@ -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 @@ -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)") } } diff --git a/Clipy/Sources/Services/PasteCountStore.swift b/Clipy/Sources/Services/PasteCountStore.swift index 80bb363..2a9d432 100644 --- a/Clipy/Sources/Services/PasteCountStore.swift +++ b/Clipy/Sources/Services/PasteCountStore.swift @@ -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 @@ -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. @@ -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 } diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 8262bef..1b04ad6 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -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) @@ -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.") } } @@ -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] {