From 54fac96deca25255a8235c089addbf1c23a0fa40 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Thu, 9 Jul 2026 22:30:43 -0400 Subject: [PATCH] Slim down app and test suite --- CONTRIBUTING.md | 1 + README.md | 2 +- docs/thingstofix.md | 15 - .../extensions/NSImage+Helpers.swift | 6 - sources/clipbored/models/ColorPayload.swift | 8 - sources/clipbored/resources/AppIcon.png | Bin 6775 -> 0 bytes .../services/ClipboardCacheService.swift | 1 - .../services/ClipboardMonitorService.swift | 3 - .../clipbored/services/ClipboardStore.swift | 83 +- .../services/DiagnosticsService.swift | 61 - .../services/SensitiveContentDetector.swift | 430 +-- .../clipbored/services/ShortcutManager.swift | 4 - .../views/ClipboardPanelController.swift | 69 - .../clipbored/views/ClipboardPanelView.swift | 2341 ++---------- .../views/ClipboardPanelViewModel.swift | 514 +-- .../views/LinkPreviewWindowController.swift | 49 - .../views/OnboardingWindowController.swift | 9 - .../views/SettingsWindowController.swift | 773 +--- .../ClipboardPanelControllerTests.swift | 335 +- .../ClipboardPanelViewModelTests.swift | 551 +-- .../ClipboardPanelViewTests.swift | 3367 ----------------- .../DiagnosticsServiceTests.swift | 26 - .../LinkPreviewWindowControllerTests.swift | 81 - .../OnboardingWindowControllerTests.swift | 21 - .../SensitiveContentDetectorTests.swift | 1 + .../SettingsPresentationTests.swift | 117 + .../SettingsWindowControllerTests.swift | 1121 ------ .../clipboredtests/ShortcutManagerTests.swift | 7 - 28 files changed, 1046 insertions(+), 8950 deletions(-) delete mode 100644 docs/thingstofix.md delete mode 100644 sources/clipbored/resources/AppIcon.png delete mode 100644 sources/clipbored/services/DiagnosticsService.swift delete mode 100644 tests/clipboredtests/ClipboardPanelViewTests.swift delete mode 100644 tests/clipboredtests/DiagnosticsServiceTests.swift delete mode 100644 tests/clipboredtests/LinkPreviewWindowControllerTests.swift create mode 100644 tests/clipboredtests/SettingsPresentationTests.swift delete mode 100644 tests/clipboredtests/SettingsWindowControllerTests.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10851f0..270d0a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ Run the full local check before opening a pull request: - Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`. - Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior. - Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes. +- Prefer behavior-level tests. Use the smoke checklist for visual layout instead of adding production debug accessors for private UI details. - Keep UI native and compact. This is a utility, not a marketing surface. ## Pull Request Checklist diff --git a/README.md b/README.md index 3ad45c0..7a7ce45 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Project layout: - `sources/clipbored/resources` - app bundle metadata and icon assets - `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters - `sources/clipbored/views` - panel, onboarding, preview, and settings UI -- `tests/clipboredtests` - unit and UI-structure regression tests +- `tests/clipboredtests` - focused behavior tests for capture, persistence, search, paste, and settings decisions - `docs` - architecture, security, release, smoke-test, and roadmap notes ## Privacy And Security diff --git a/docs/thingstofix.md b/docs/thingstofix.md deleted file mode 100644 index 6bf5737..0000000 --- a/docs/thingstofix.md +++ /dev/null @@ -1,15 +0,0 @@ -# UI Cleanup Resolution - -All eleven findings from the original cleanup list are resolved in the current shelf design. - -1. [x] **Hover controls obscured clip text.** Hover now expands the preview visually; commands are exposed through the context menu and keyboard, so no hover rail covers content. -2. [x] **Hover broke keyboard navigation.** Hover state is independent from keyboard focus and selection, and arrow navigation clears stale hover ownership without changing the wrong clip. -3. [x] **Category changes felt abrupt.** Search, category, card, and panel changes use short eased transitions and become immediate when macOS Reduce Motion is enabled. -4. [x] **The collapsed search control was broken.** Search has one aligned container: it expands on click, typing, or `Command + F`, and collapses after click-away only when the query is empty. -5. [x] **Filtering was split across categories and a search menu.** Category chips are the primary visual filters; a click replaces the filter and Command-click builds a union. `Command + F` only focuses search. -6. [x] **Empty built-in categories added noise.** Built-in type/sort chips are created only when they have matches or are selected; empty custom Pinboards remain visible by design. -7. [x] **A nonfunctional resize lip was visible.** The shelf has no resize handle and uses its fixed side-shelf frame. -8. [x] **New Text Clip was unnecessary.** The panel, menu-bar menu, and shortcut map no longer expose a new-text action. -9. [x] **The alternate compact mode was unnecessary.** Settings exposes one Side Shelf layout; row sizing adapts automatically to available space instead of presenting a mode toggle. -10. [x] **The panel did not need a close control.** There is no close button in shelf chrome; `Esc` and the configured global shortcut dismiss it. -11. [x] **The persistent status bar consumed space.** The shelf flows directly from its toolbar into the side-rail card list; feedback is shown in the relevant menu or Settings page. diff --git a/sources/clipbored/extensions/NSImage+Helpers.swift b/sources/clipbored/extensions/NSImage+Helpers.swift index 4eaa229..aa28b24 100644 --- a/sources/clipbored/extensions/NSImage+Helpers.swift +++ b/sources/clipbored/extensions/NSImage+Helpers.swift @@ -62,9 +62,3 @@ extension NSImage { ) } } - -extension NSView { - var isInAnyViewHierarchy: Bool { - return window != nil - } -} diff --git a/sources/clipbored/models/ColorPayload.swift b/sources/clipbored/models/ColorPayload.swift index 9304322..f51275b 100644 --- a/sources/clipbored/models/ColorPayload.swift +++ b/sources/clipbored/models/ColorPayload.swift @@ -61,14 +61,6 @@ enum ColorPayload { "\(displayHex(from: payload))\n\(componentSummary(from: payload))" } - static func contrastingTextColor(for color: NSColor) -> NSColor { - guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else { - return .labelColor - } - let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent) - return luminance > 0.62 ? NSColor.black.withAlphaComponent(0.82) : .white - } - private static func clampedByte(_ value: CGFloat) -> Int { Int((min(1, max(0, value)) * 255).rounded()) } diff --git a/sources/clipbored/resources/AppIcon.png b/sources/clipbored/resources/AppIcon.png deleted file mode 100644 index 9c7b4a536f7421c4daa69bed296208e584bc5632..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6775 zcmd5>dsGu=7XRV{)K&4+R*SWQXI1tb)@_Jj2nmU{AR;0vZXy9ffl@{sk3 zu5zkf2_cXb&newTNUP8gOpK4(99AaLBXd}BNKooT;}XOaV;BF2UY=a z@Gtr6qNw3fRC*|t8bzfZoAcfE|1;QEke&NQ`P&Ax zC|cNrL9cd`2pat6zjr9k-Is$D@7h=RmQ7fARQQ?+n+2WtNdP`H#>cGRvA=h)FX7VC zxYF3WHxy?!NvgocMe7f)AUH9n`omjNmN;r(_PS@8ju&R!*GkB`74c`@*?wTTj;|Bw zQgrD$W|f)&baK6X zD;({vT&r9$`x?$h8ja7e2Zjhk|K?G%yzYihoTf7NnOyS1aD}kJLH@4%W&CkJXg=9! z@OnIfMLHdq&kfI!o)f0HTV=elOR|bGv#rMvaioriD+VfuY*tKonh6xVd&=F4!+YLX z`Ub_qbhLoIy0)S$zn%P>dtXGsh}Bj}V{KKC%~$1hs%&vDhl|um=l*gDl7MeO>kZN2 zI!l(gcSy+yd{Q|eT(-ZF5XwoHv`-2=`AwCGx2d*sjiX3T^?{ z5rfu8uC7(fOkolX?=IjWY@_>xOGzMIsc2b5r&NHdnLX{(sTS;V(x>6xXPrDccpcd{ zWYn%v5sT%&K17)7Kg!R)t66CM0P!=`Ajqkwn43W~+bkjd$0!>Ugd}pG0HebM~vIm zPMkH}8cOn9Q!xDl(av=ip5!8U?rTmM5VBjYHVc}GFe!7GoxMjeJc^TugIi!iYa)AI zeJ5LGtcf(dhlVKX9vUF+`kogM+MOm$?xN90M7^je&m=nKzgA+ezpD93i@DPi1BpkD zZeu!aoa6i80%5vHfF@N~201Di;Lr9g+wWagL2YDct!enShI~}OweS$KYyAe_Gl5-E zy2bB+T1MvCesZzM7~|?M`e) z|2`JrbvG^`ZLBi7@Z25YN(f~uwlsDMxOp@0bg3rEZ!|X|t{V@Tjs~*I zM1)Jaw=0x*$A95_jP}{8VHy?plG`eR1Cef{`x`X1FXcdRAV+0SVymP-m?Vc7oIJDbe#r7A z@ZC>hkLX3(|8%Ih-1VX;f-`sw6D6DMh}grTCkRWZ;t^D`Bcf#KZ?>vDPJy&ZfaK@byaHlRsQjCfFJ5fgoTg z^mz@}d#vUbySjrORCx7A5*aJb7guXujnSpFuBp%)AR(T1nMG(n1Iz8A1|HtjH}-=p zxdL`H%xq-X^)sYcbDB`x>nbR;OoiyC)iQb?S7XO8cejyfUbcU(eQ(#HH6~a3QHIef zzLh3q_d{Z+>AJpMukwBMiVlUDTuc4d9Gjb%ZEhxxR_JBwj`44uR>Am8G$5j{J|MD3 zI+VwDv8*tIjcuI*@NKGJlR-4OTRSW^1D2g@_I?eN){Q>UxHb_cood*P!x+1Sr!_wb zuPnSY1&Y`YyR3+FVTq;DaRRQm5L+d$D?~qzS6l5TE$bT+X+-b^-+QUzT8w=GR=f~7 zrO!NR*2(tb3Lz}Er(5jqd*&}|FMQ*Bu}D(Wv+ qcj#k)*0)90yZhtd%nDt*C!n!n)$)=Exi`Io_}DEmEuVh=r+)!JrCzE4 diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index 5b6756b..e329c0f 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -329,7 +329,6 @@ final class ClipboardCacheService { func purgeIfNeeded(maxBytes: Int64) { queue.async { - DiagnosticsService.shared.incrementCachePurge() let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? [] var items: [(url: URL, size: Int64, date: Date)] = [] var totalSize: Int64 = 0 diff --git a/sources/clipbored/services/ClipboardMonitorService.swift b/sources/clipbored/services/ClipboardMonitorService.swift index ac2e6e2..4036e32 100644 --- a/sources/clipbored/services/ClipboardMonitorService.swift +++ b/sources/clipbored/services/ClipboardMonitorService.swift @@ -108,7 +108,6 @@ final class ClipboardMonitorService { } private func tick() { - DiagnosticsService.shared.incrementMonitorTick() pollPasteboard(rescheduleAfterCapture: true) } @@ -138,7 +137,6 @@ final class ClipboardMonitorService { return } - DiagnosticsService.shared.incrementPasteboardChange() didReportReadFailure = false if let item = readCurrentItem(from: pasteboard) { @@ -162,7 +160,6 @@ final class ClipboardMonitorService { } private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? { - DiagnosticsService.shared.incrementExtractionAttempt() let source = frontmostApp() func isIgnored(_ kind: ClipboardItemKind) -> Bool { diff --git a/sources/clipbored/services/ClipboardStore.swift b/sources/clipbored/services/ClipboardStore.swift index dfe0a69..46a8b4b 100644 --- a/sources/clipbored/services/ClipboardStore.swift +++ b/sources/clipbored/services/ClipboardStore.swift @@ -659,78 +659,22 @@ final class ClipboardStore { } private func legacyISO8601Date(_ string: String) -> Date? { - string.withCString { pointer -> Date? in - let byteCount = strlen(pointer) - guard byteCount >= 20, - byte(pointer, 4) == 45, - byte(pointer, 7) == 45, - byte(pointer, 10) == 84 || byte(pointer, 10) == 32, - byte(pointer, 13) == 58, - byte(pointer, 16) == 58, - let year = decimal(pointer, byteCount, 0, 4), - let month = decimal(pointer, byteCount, 5, 2), - let day = decimal(pointer, byteCount, 8, 2), - let hour = decimal(pointer, byteCount, 11, 2), - let minute = decimal(pointer, byteCount, 14, 2), - let second = decimal(pointer, byteCount, 17, 2) - else { - return nil - } + let value = string.replacingOccurrences(of: " ", with: "T") + return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first + } - var cursor = 19 - var fraction = 0.0 - if cursor < byteCount, byte(pointer, cursor) == 46 { - cursor += 1 - var scale = 0.1 - while cursor < byteCount { - let digit = byte(pointer, cursor) - guard digit >= 48, digit <= 57 else { break } - fraction += Double(digit - 48) * scale - scale /= 10 - cursor += 1 - } - } - - var offset = 0 - if cursor < byteCount, byte(pointer, cursor) == 90 { - offset = 0 - } else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 { - let sign = byte(pointer, cursor) == 43 ? 1 : -1 - guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2), - let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2) - else { return nil } - offset = sign * ((offsetHour * 3600) + (offsetMinute * 60)) - } - - var components = tm() - components.tm_year = Int32(year - 1900) - components.tm_mon = Int32(month - 1) - components.tm_mday = Int32(day) - components.tm_hour = Int32(hour) - components.tm_min = Int32(minute) - components.tm_sec = Int32(second) - components.tm_isdst = 0 - - let epoch = timegm(&components) - guard epoch >= 0 else { return nil } - return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction) + private static let legacyDateFormatters: [ISO8601DateFormatter] = { + let formats: [ISO8601DateFormatter.Options] = [ + [.withInternetDateTime, .withFractionalSeconds], + [.withInternetDateTime] + ] + return formats.map { options in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = options + return formatter } - } + }() - private func decimal(_ pointer: UnsafePointer, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? { - guard start + length <= byteCount else { return nil } - var result = 0 - for index in start..<(start + length) { - let digit = byte(pointer, index) - guard digit >= 48, digit <= 57 else { return nil } - result = (result * 10) + Int(digit - 48) - } - return result - } - - private func byte(_ pointer: UnsafePointer, _ index: Int) -> UInt8 { - UInt8(bitPattern: pointer[index]) - } private func isDatabaseEmpty() -> Bool { guard let db else { return true } @@ -901,7 +845,6 @@ final class ClipboardStore { private func applyPersistence(_ mutation: PersistenceMutation) { guard let db else { return } - DiagnosticsService.shared.incrementDatabaseMutation() let insertSQL = """ INSERT OR REPLACE INTO clipboard_items ( id, kind, display_text, payload, payload_hash, diff --git a/sources/clipbored/services/DiagnosticsService.swift b/sources/clipbored/services/DiagnosticsService.swift deleted file mode 100644 index cd4e458..0000000 --- a/sources/clipbored/services/DiagnosticsService.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -final class DiagnosticsService { - static let shared = DiagnosticsService() - - struct Snapshot: Equatable { - var monitorTicks: Int - var pasteboardChanges: Int - var extractionAttempts: Int - var databaseMutations: Int - var cachePurges: Int - } - - private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility) - private var snapshot = Snapshot( - monitorTicks: 0, - pasteboardChanges: 0, - extractionAttempts: 0, - databaseMutations: 0, - cachePurges: 0 - ) - - private init() {} - - func incrementMonitorTick() { - queue.async { self.snapshot.monitorTicks += 1 } - } - - func incrementPasteboardChange() { - queue.async { self.snapshot.pasteboardChanges += 1 } - } - - func incrementExtractionAttempt() { - queue.async { self.snapshot.extractionAttempts += 1 } - } - - func incrementDatabaseMutation() { - queue.async { self.snapshot.databaseMutations += 1 } - } - - func incrementCachePurge() { - queue.async { self.snapshot.cachePurges += 1 } - } - - func currentSnapshot() -> Snapshot { - queue.sync { snapshot } - } - - func reset() { - queue.sync { - snapshot = Snapshot( - monitorTicks: 0, - pasteboardChanges: 0, - extractionAttempts: 0, - databaseMutations: 0, - cachePurges: 0 - ) - } - } - -} diff --git a/sources/clipbored/services/SensitiveContentDetector.swift b/sources/clipbored/services/SensitiveContentDetector.swift index eb16e0c..411820f 100644 --- a/sources/clipbored/services/SensitiveContentDetector.swift +++ b/sources/clipbored/services/SensitiveContentDetector.swift @@ -17,369 +17,143 @@ enum SensitiveContentDetector { case keyword } - static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? { - let trimmed = text.clipboardTrimmed - guard !trimmed.isEmpty else { return nil } - let bytes = Array(trimmed.utf8) + private static let tokenPatterns: [(Reason, NSRegularExpression)] = [ + (.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)), + (.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)), + (.slackToken, regex(#"\bxox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9_])"#)), + (.awsAccessKey, regex(#"\bAKIA[A-Z0-9]{16}(?![A-Za-z0-9_])"#)), + (.stripeKey, regex(#"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9_])"#)), + (.openAIToken, regex(#"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])"#)), + (.googleAPIKey, regex(#"\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_])"#)), + (.jsonWebToken, regex(#"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])"#)) + ] - if containsPrivateKey(trimmed) { return .privateKey } - if containsBearerToken(bytes) { return .bearerToken } - if containsGitHubToken(bytes) { return .githubToken } - if containsSlackToken(bytes) { return .slackToken } - if containsAWSAccessKey(bytes) { return .awsAccessKey } - if containsStripeKey(bytes) { return .stripeKey } - if containsOpenAIToken(bytes) { return .openAIToken } - if containsGoogleAPIKey(bytes) { return .googleAPIKey } - if containsJSONWebToken(bytes) { return .jsonWebToken } - if containsCreditCard(trimmed) { return .creditCard } - if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode } - if looksHighEntropy(trimmed) { return .highEntropyToken } + static func detect( + _ text: String, + sourceBundleId: String? = nil, + sourceApp: String? = nil + ) -> Reason? { + let value = text.clipboardTrimmed + guard !value.isEmpty else { return nil } - let lowered = trimmed.lowercased() - if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) { + if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") { + return .privateKey + } + if let match = tokenPatterns.first(where: { matches($0.1, in: value) }) { + return match.0 + } + if containsCreditCard(value) { return .creditCard } + if looksLikeOneTimeCode(value, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { + return .oneTimeCode + } + if looksHighEntropy(value) { return .highEntropyToken } + + let lowered = value.lowercased() + if lowered.contains("password") + || lowered.contains("secret") + || lowered.contains("api_key") + || looksLikeSecretAssignment(lowered) { return .keyword } - return nil } - static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool { + static func isLikelySensitive( + _ text: String, + sourceBundleId: String? = nil, + sourceApp: String? = nil + ) -> Bool { detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil } - private static func containsPrivateKey(_ text: String) -> Bool { - text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----") + private static func regex(_ pattern: String) -> NSRegularExpression { + try! NSRegularExpression(pattern: pattern) + } + + private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool { + regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil } private static func looksHighEntropy(_ text: String) -> Bool { - let candidate = text.clipboardTrimmed - guard candidate.count >= 32, candidate.count <= 256 else { return false } - guard !candidate.contains(where: { $0.isWhitespace }) else { return false } - - var hasLower = false - var hasUpper = false - var hasDigit = false - var symbolCount = 0 - - for scalar in candidate.unicodeScalars { - let value = scalar.value - if value >= 48, value <= 57 { - hasDigit = true - } else if value >= 65, value <= 90 { - hasUpper = true - } else if value >= 97, value <= 122 { - hasLower = true - } else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 { - symbolCount += 1 - } else { - return false - } + guard (32...256).contains(text.count), + !text.contains(where: \.isWhitespace) else { + return false } - let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0) - return classCount >= 2 && symbolCount > 0 + var characterClasses = 0 + var hasSymbol = false + for scalar in text.unicodeScalars { + switch scalar.value { + case 48...57: characterClasses |= 1 + case 65...90: characterClasses |= 2 + case 97...122: characterClasses |= 4 + case 43, 45, 46, 47, 61, 95: hasSymbol = true + default: return false + } + } + return characterClasses.nonzeroBitCount >= 2 && hasSymbol } - private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool { - let value = text.clipboardTrimmed - guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false } - - let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased() - guard !source.isEmpty else { return false } - return source.contains("auth") || - source.contains("1password") || - source.contains("bitwarden") || - source.contains("lastpass") || - source.contains("keeper") || - source.contains("dashlane") + private static func looksLikeOneTimeCode( + _ text: String, + sourceBundleId: String?, + sourceApp: String? + ) -> Bool { + guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else { + return false + } + let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased() + return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"] + .contains(where: source.contains) } private static func containsCreditCard(_ text: String) -> Bool { var digits: [Int] = [] + func isCard(_ digits: [Int]) -> Bool { + guard (13...19).contains(digits.count), + let first = digits.first, + digits.contains(where: { $0 != first }) else { + return false + } + var sum = 0 + for (index, digit) in digits.reversed().enumerated() { + let doubled = index.isMultiple(of: 2) ? digit : digit * 2 + sum += doubled > 9 ? doubled - 9 : doubled + } + return sum.isMultiple(of: 10) + } - for char in text { - if char.isNumber, let digit = char.wholeNumberValue { + for character in text { + if let digit = character.wholeNumberValue { digits.append(digit) + } else if (character == " " || character == "-"), !digits.isEmpty { + continue } else { - if isCreditCardGroup(digits) { - return true - } + if isCard(digits) { return true } digits.removeAll(keepingCapacity: true) } } - - return isCreditCardGroup(digits) + return isCard(digits) } - private static func isCreditCardGroup(_ digits: [Int]) -> Bool { - guard digits.count >= 13, digits.count <= 19, let first = digits.first else { - return false - } - guard digits.contains(where: { $0 != first }) else { - return false - } - return passesLuhn(digits) - } - - private static func passesLuhn(_ digits: [Int]) -> Bool { - var sum = 0 - var shouldDouble = false - - for digit in digits.reversed() { - var value = digit - if shouldDouble { - value *= 2 - if value > 9 { - value -= 9 - } - } - sum += value - shouldDouble.toggle() - } - - return sum % 10 == 0 - } - - private static func containsBearerToken(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 27 else { return false } - for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) { - guard matchesBearer(bytes, index) else { continue } - var cursor = index + 6 - guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue } - while cursor < bytes.count, isWhitespace(bytes[cursor]) { - cursor += 1 - } - let start = cursor - while cursor < bytes.count, isBearerByte(bytes[cursor]) { - cursor += 1 - } - if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 34 else { return false } - for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) { - let marker = bytes[index + 2] - guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else { - continue - } - var cursor = index + 4 - while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 { - cursor += 1 - } - if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsSlackToken(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 25 else { return false } - for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) { - let marker = bytes[index + 3] - guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else { - continue - } - var cursor = index + 5 - while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 { - cursor += 1 - } - if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 20 else { return false } - for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) { - guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue } - var cursor = index + 4 - while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) { - cursor += 1 - } - if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsStripeKey(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 24 else { return false } - for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) { - let prefix = bytes[index] - guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue } - let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95 - let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95 - guard live || test else { continue } - var cursor = index + 8 - while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) { - cursor += 1 - } - if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 24 else { return false } - for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) { - guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue } - var cursor = index + 3 - if cursor + 5 <= bytes.count, - bytes[cursor] == 112, - bytes[cursor + 1] == 114, - bytes[cursor + 2] == 111, - bytes[cursor + 3] == 106, - bytes[cursor + 4] == 45 { - cursor += 5 - } - let tokenStart = cursor - while cursor < bytes.count, isTokenByte(bytes[cursor]) { - cursor += 1 - } - if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 39 else { return false } - for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) { - guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue } - var cursor = index + 4 - while cursor < index + 39, isTokenByte(bytes[cursor]) { - cursor += 1 - } - if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) { - return true - } - } - return false - } - - private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool { - guard bytes.count >= 32 else { return false } - var index = 0 - while index + 3 < bytes.count { - guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else { - index += 1 - continue - } - - var cursor = index - let firstStart = cursor - while cursor < bytes.count, isBase64URLByte(bytes[cursor]) { - cursor += 1 - } - guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else { - index += 1 - continue - } - - cursor += 1 - let secondStart = cursor - while cursor < bytes.count, isBase64URLByte(bytes[cursor]) { - cursor += 1 - } - guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else { - index += 1 - continue - } - - cursor += 1 - let thirdStart = cursor - while cursor < bytes.count, isBase64URLByte(bytes[cursor]) { - cursor += 1 - } - if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) { - return true - } - index += 1 - } - return false - } - - private static func looksLikeSecretAssignment(_ lowered: String) -> Bool { + private static func looksLikeSecretAssignment(_ text: String) -> Bool { let keys = [ - "api_key", - "apikey", - "access_token", - "auth_token", - "client_secret", - "private_token", - "refresh_token", - "secret_key", - "passwd" + "api_key", "apikey", "access_token", "auth_token", "client_secret", + "private_token", "refresh_token", "secret_key", "passwd" ] - for key in keys { - guard let range = lowered.range(of: key) else { continue } - let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace }) - guard let separator = suffix.first, separator == "=" || separator == ":" else { continue } - let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" }) - let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count - if valueLength >= 8 { + guard let range = text.range(of: key) else { continue } + let suffix = text[range.upperBound...].drop(while: \.isWhitespace) + guard suffix.first == "=" || suffix.first == ":" else { continue } + let value = suffix.dropFirst().drop { + $0.isWhitespace || $0 == "\"" || $0 == "'" + } + if value.prefix(while: { + !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," + }).count >= 8 { return true } } - return false } - - private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool { - (bytes[index] == 98 || bytes[index] == 66) && - (bytes[index + 1] == 101 || bytes[index + 1] == 69) && - (bytes[index + 2] == 97 || bytes[index + 2] == 65) && - (bytes[index + 3] == 114 || bytes[index + 3] == 82) && - (bytes[index + 4] == 101 || bytes[index + 4] == 69) && - (bytes[index + 5] == 114 || bytes[index + 5] == 82) - } - - private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool { - index == 0 || !isWordByte(bytes[index - 1]) - } - - private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool { - index >= bytes.count || !isWordByte(bytes[index]) - } - - private static func isWordByte(_ byte: UInt8) -> Bool { - isAlphaNumeric(byte) || byte == 95 - } - - private static func isAlphaNumeric(_ byte: UInt8) -> Bool { - (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) - } - - private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool { - (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) - } - - private static func isBearerByte(_ byte: UInt8) -> Bool { - isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61 - } - - private static func isTokenByte(_ byte: UInt8) -> Bool { - isAlphaNumeric(byte) || byte == 95 || byte == 45 - } - - private static func isBase64URLByte(_ byte: UInt8) -> Bool { - isAlphaNumeric(byte) || byte == 95 || byte == 45 - } - - private static func isWhitespace(_ byte: UInt8) -> Bool { - byte == 32 || byte == 9 || byte == 10 || byte == 13 - } } diff --git a/sources/clipbored/services/ShortcutManager.swift b/sources/clipbored/services/ShortcutManager.swift index aec29cf..2b694cc 100644 --- a/sources/clipbored/services/ShortcutManager.swift +++ b/sources/clipbored/services/ShortcutManager.swift @@ -261,10 +261,6 @@ final class ShortcutManager { modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue ) - static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] { - [openShortcut, stackCaptureShortcut] - } - private func osStatusMessage(_ status: OSStatus) -> String { "OSStatus \(status)" } diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index d497225..d54a417 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -1,13 +1,6 @@ import AppKit import QuickLookUI -struct ClipboardPanelAnimationProfile { - let showDuration: TimeInterval - let hideDuration: TimeInterval - let reflowDuration: TimeInterval - let easing: CAMediaTimingFunctionName -} - struct ClipboardPanelReflowPlan { let frame: NSRect let bottomSafeInset: CGFloat @@ -360,10 +353,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback } - static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) { - return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame) - } - static func panelFrames( forScreenFrame screenFrame: CGRect, visibleFrame: CGRect, @@ -422,27 +411,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0 } - static var animationProfile: ClipboardPanelAnimationProfile { - ClipboardPanelAnimationProfile( - showDuration: Animation.showDuration, - hideDuration: Animation.hideDuration, - reflowDuration: Animation.reflowDuration, - easing: Animation.easing - ) - } - static var panelCollectionBehavior: NSWindow.CollectionBehavior { [.moveToActiveSpace, .fullScreenAuxiliary, .transient] } - static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan { - let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - return ClipboardPanelReflowPlan( - frame: frames.shown, - bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - ) - } - static func reflowPlan( forScreenFrame screenFrame: CGRect, visibleFrame: CGRect, @@ -848,47 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel quickLookURL as NSURL? } - #if DEBUG - var debugPanelFrame: NSRect { - panel.frame - } - - var debugPanelAlpha: CGFloat { - panel.alphaValue - } - - var debugIsAnimating: Bool { - isAnimating - } - - func debugSetSearchFieldText(_ text: String) { - panelView.debugSetSearchFieldText(text) - } - - var debugSearchFieldText: String { - panelView.debugSearchFieldText - } - - var debugSearchFieldWidth: CGFloat { - panelView.debugSearchFieldWidth - } - - var debugSearchFieldPlaceholderText: String { - panelView.debugSearchFieldPlaceholderText - } - - var debugSearchFieldIsVisible: Bool { - panelView.debugSearchFieldIsVisible - } - - var debugSearchIconButtonIsVisible: Bool { - panelView.debugSearchIconButtonIsVisible - } - - var debugIsSearchFieldEditing: Bool { - panelView.isSearchFieldEditing - } - #endif private func installClickMonitor() { removeClickMonitor() diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index d303a8e..e681f77 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -110,10 +110,6 @@ private enum ClipboardCollectionVisuals { } } - static func defaultColorHex(forCollectionNamed name: String) -> String { - hexString(for: defaultColor(forCollectionNamed: name)) - } - static func hexString(for color: NSColor) -> String { let rgb = color.usingColorSpace(.deviceRGB) ?? color let red = Int((rgb.redComponent * 255).rounded()) @@ -191,6 +187,15 @@ private final class ClipboardPanelSearchFieldCell: NSSearchFieldCell { .zero } + override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText { + let editor = super.setUpFieldEditorAttributes(textObj) + if let textView = editor as? NSTextView { + textView.drawsBackground = false + textView.backgroundColor = .clear + } + return editor + } + override func cancelButtonRect(forBounds rect: NSRect) -> NSRect { guard cancelButtonCell != nil else { return .zero } return NSRect( @@ -375,9 +380,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private var shelfChromeHeightConstraint: NSLayoutConstraint? private var searchControlCenterYConstraint: NSLayoutConstraint? private var utilityToolbarCenterYConstraint: NSLayoutConstraint? - private var collectionScrollCenterYConstraint: NSLayoutConstraint? - private var collectionAvoidsSearchConstraint: NSLayoutConstraint? - private var collectionAvoidsActionsConstraint: NSLayoutConstraint? private weak var shelfChromeView: NSView? private weak var headerStack: NSStackView? private weak var contentStack: NSView? @@ -400,15 +402,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private var hoverSelectionRequiresFreshMouseMovement = false private var hoverSelectionKeyboardBarrierLocation: NSPoint? private var cardSelectionInputSource: CardSelectionInputSource = .mouse - #if DEBUG - private var animatedCardLayoutChangeCount = 0 - private var debugItemReloadCountValue = 0 - #endif private var defersVisualReloads = false private var pendingItemReload = false private var pendingCollectionReload = false private var cardReloadGeneration = 0 - private var lastSynchronousCardRenderCount = 0 private weak var activeWritingToolsTextView: NSTextView? private struct FocusedCardReloadTarget { @@ -431,15 +428,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let cardRenderRetentionBuffer = 8 private var currentCardRenderContext: CardRenderContext? - #if DEBUG - private var collectionNameProviderForTesting: (() -> String?)? - private var suppressClipEditDialogsForTesting = false - private var renameClipRequestCountForTesting = 0 - private var editClipRequestCountForTesting = 0 - private var suppressClipOpenActionsForTesting = false - private var openClipRequestCountForTesting = 0 - private var showInClipboardRequestCountForTesting = 0 - #endif init( viewModel: ClipboardPanelViewModel, @@ -494,12 +482,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.sendsWholeSearchString = false searchField.isBezeled = false searchField.drawsBackground = false - searchField.placeholderAttributedString = NSAttributedString( - string: "Search clips", - attributes: [ - .foregroundColor: NSColor.tertiaryLabelColor - ] - ) searchField.backgroundColor = .clear searchField.font = .systemFont(ofSize: 13, weight: .regular) searchField.focusRingType = .none @@ -1127,9 +1109,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func reloadItems() { - #if DEBUG - debugItemReloadCountValue += 1 - #endif cardReloadGeneration += 1 let reloadGeneration = cardReloadGeneration let focusedCardReloadTarget = focusedCardReloadTarget() @@ -1148,7 +1127,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { let items = viewModel.visibleItems let shouldAnimateReload = window != nil && !defersVisualReloads if items.isEmpty { - lastSynchronousCardRenderCount = 0 emptyStateText = emptyStateCopy() scrollView.documentView = emptyStateView() } else { @@ -1175,7 +1153,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { sizeItemsDocument(itemCount: items.count) let synchronousRange = initialCardRenderRange(itemCount: items.count) - lastSynchronousCardRenderCount = synchronousRange.count renderCards(in: synchronousRange, reloadGeneration: reloadGeneration) } @@ -1609,13 +1586,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.pasteboardWriters(forItemAt: selected) ?? [] } card.onOpen = { [weak self] selected in - #if DEBUG - self?.openClipRequestCountForTesting += 1 - if self?.suppressClipOpenActionsForTesting == true { - self?.viewModel.selectItem(at: selected) - return - } - #endif self?.viewModel.selectItem(at: selected) self?.viewModel.openSelected() } @@ -1898,12 +1868,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func editText(at index: Int) { viewModel.selectItem(at: index) guard let currentText = viewModel.editableTextForSelected() else { return } - #if DEBUG - editClipRequestCountForTesting += 1 - if suppressClipEditDialogsForTesting { - return - } - #endif let textView = Self.makePlainTextEditor(initialText: currentText) let accessoryView = textEditorAccessoryView(for: textView) @@ -1932,12 +1896,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func renameClip(at index: Int) { viewModel.selectItem(at: index) guard let currentTitle = viewModel.editableTitleForSelected() else { return } - #if DEBUG - renameClipRequestCountForTesting += 1 - if suppressClipEditDialogsForTesting { - return - } - #endif let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) input.placeholderString = "Clip title" @@ -2013,9 +1971,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return } - #if DEBUG - animatedCardLayoutChangeCount += 1 - #endif NSAnimationContext.runAnimationGroup { context in context.duration = Motion.duration(Motion.cardExpansionDuration) @@ -2250,8 +2205,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { || searchField.isHidden == isActive || searchIconButton.isHidden searchFieldPresentationIsExpanded = isActive + let placeholder = isActive && !isSearchFieldEditing ? "Search clips" : "" searchField.placeholderAttributedString = NSAttributedString( - string: isActive ? "Search clips" : "", + string: placeholder, attributes: [ .foregroundColor: NSColor.tertiaryLabelColor ] @@ -2402,1122 +2358,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { flushDeferredVisualReloads() } - #if DEBUG - var debugVisibleCardCount: Int { - cardViews.count - } - - var debugCardSlotCount: Int { - cardSlots.count - } - - var debugCardItemCount: Int { - cardItemCount - } - - var debugItemReloadCount: Int { - debugItemReloadCountValue - } - - var debugLastSynchronousCardRenderCount: Int { - lastSynchronousCardRenderCount - } - - var debugIsDeferringVisualReloads: Bool { - defersVisualReloads - } - - var debugDocumentViewFrame: NSRect { - scrollView.documentView?.frame ?? .zero - } - - var debugDocumentViewIsCardStack: Bool { - scrollView.documentView === itemsStack - } - - var debugContentInsets: NSEdgeInsets { - mainStack?.edgeInsets ?? NSEdgeInsets() - } - - var debugPanelCornerRadius: CGFloat { - layer?.cornerRadius ?? 0 - } - - var debugPanelMaterial: NSVisualEffectView.Material { - material - } - - var debugPanelSurfaceAlpha: CGFloat { - Self.debugAlpha(layer?.backgroundColor) - } - - var debugPanelBorderAlpha: CGFloat { - Self.debugAlpha(layer?.borderColor) - } - - var debugPanelShadowOpacity: Float { - layer?.shadowOpacity ?? 0 - } - - var debugCardAccessibilityLabels: [String] { - cardViews.compactMap { $0.accessibilityLabel() } - } - - var debugCardAccessibilityValues: [String] { - cardViews.compactMap { $0.accessibilityValue() as? String } - } - - var debugCardAccessibilityHelps: [String] { - cardViews.compactMap { $0.accessibilityHelp() } - } - - var debugCardAcceptsFirstResponder: [Bool] { - cardViews.map(\.acceptsFirstResponder) - } - - var debugKeyboardFocusedCardIndexes: [Int] { - cardViews.enumerated().compactMap { index, card in - card.debugIsKeyboardFocused ? index : nil - } - } - - var debugSelectedCardIndexes: [Int] { - cardViews.enumerated().compactMap { index, card in - card.debugIsSelected ? index : nil - } - } - - var debugActiveCardIndexes: [Int] { - cardViews.enumerated().compactMap { index, card in - card.debugIsActiveSelection ? index : nil - } - } - - var debugCardBorderWidths: [CGFloat] { - cardViews.map(\.debugBorderWidth) - } - - var debugCardPreviewSummaries: [String] { - cardViews.map(\.debugPreviewSummary) - } - - var debugCardTextPreviewTitles: [String] { - cardViews.map(\.debugTextPreviewTitle) - } - - var debugCardTextPreviewBodies: [String] { - cardViews.map(\.debugTextPreviewBody) - } - - var debugCardTextPreviewChromes: [String] { - cardViews.map(\.debugTextPreviewChrome) - } - - var debugCardTextPreviewAccentHexes: [String] { - cardViews.map(\.debugTextPreviewAccentHex) - } - - var debugCardTextPreviewFadePlacements: [String] { - cardViews.map(\.debugTextPreviewFadePlacement) - } - - var debugCardPreviewStyles: [String] { - cardViews.map(\.debugPreviewStyle) - } - - var debugCardDensity: String { - cardDensity.rawValue - } - - var debugPanelLayout: String { - "Side Shelf" - } - - var debugResizeHandleIsHidden: Bool { - true - } - - var debugItemsStackOrientation: NSUserInterfaceLayoutOrientation { - itemsStack.orientation - } - - private var debugToolbarButtons: [NSButton] { - [clearHistoryButton, settingsButton].compactMap { $0 } - } - - var debugToolbarButtonAccessibilityLabels: [String] { - debugToolbarButtons.compactMap { $0.accessibilityLabel() } - } - - var debugToolbarButtonAccessibilityHelps: [String] { - debugToolbarButtons.compactMap { $0.accessibilityHelp() } - } - - var debugUtilityToolbarGroupBackgroundAlpha: CGFloat { - Self.debugAlpha(utilityToolbarGroup?.layer?.backgroundColor) - } - - var debugUtilityToolbarGroupCornerRadius: CGFloat { - utilityToolbarGroup?.layer?.cornerRadius ?? 0 - } - - var debugToolbarButtonBackgroundAlphas: [CGFloat] { - debugToolbarButtons.map { Self.debugAlpha($0.layer?.backgroundColor) } - } - - var debugToolbarButtonBorderWidths: [CGFloat] { - debugToolbarButtons.map { $0.layer?.borderWidth ?? 0 } - } - - var debugSearchFieldAccessibilityHelp: String { - searchField.accessibilityHelp() ?? "" - } - - var debugAddCollectionButtonAccessibilityHelp: String { - addCollectionButton.accessibilityHelp() ?? "" - } - - var debugCardSizes: [NSSize] { - cardViews.map { $0.frame.size } - } - - var debugCardHeaderFramesInPanel: [NSRect] { - cardViews.map { card in - card.convert(card.debugHeaderFrame, to: self) - } - } - - var debugFirstCardHeaderMaskedCorners: CACornerMask { - cardViews.first?.debugHeaderMaskedCorners ?? [] - } - - var debugCardExpandedDetailFramesInPanel: [NSRect] { - cardViews.map { card in - card.convert(card.debugExpandedDetailFrame, to: self) - } - } - - var debugCardExpandedDetailPresentationHeights: [CGFloat] { - cardViews.map(\.debugExpandedDetailPresentationHeight) - } - - var debugCardSlotPresentationFramesInPanel: [NSRect] { - cardSlots.keys.sorted().compactMap { index in - guard let slot = cardSlots[index], - let superview = slot.superview else { - return nil - } - return superview.convert(slot.debugPresentationFrame, to: self) - } - } - - var debugCardContentCornerRadii: [CGFloat] { - cardViews.map(\.debugContentCornerRadius) - } - - var debugCardContentBackgroundAlphas: [CGFloat] { - cardViews.map(\.debugContentBackgroundAlpha) - } - - var debugCardPresentations: [String] { - cardViews.map(\.debugPresentation) - } - - var debugCardAnimatedPresentationChangeCounts: [Int] { - cardViews.map(\.debugAnimatedPresentationChangeCount) - } - - var debugCardHeaderBadgeSymbols: [String] { - cardViews.map(\.debugHeaderBadgeSymbol) - } - - var debugCardHeaderBadgeTexts: [String] { - cardViews.map(\.debugHeaderBadgeText) - } - - var debugCardCompactMetricTexts: [String] { - cardViews.map(\.debugCompactMetricText) - } - - var debugCardCompactMetricHiddenStates: [Bool] { - cardViews.map(\.debugCompactMetricIsHidden) - } - - var debugCardCompactMetricFramesInPanel: [NSRect] { - cardViews.map { card in - card.convert(card.debugCompactMetricFrame, to: self) - } - } - - var debugCardCompactMetricFittingWidths: [CGFloat] { - cardViews.map(\.debugCompactMetricFittingWidth) - } - - var debugFirstCardHeaderTitle: String { - cardViews.first?.debugHeaderTitle ?? "" - } - - var debugFirstCardHeaderSubtitle: String { - cardViews.first?.debugHeaderSubtitle ?? "" - } - - var debugFirstCardHeaderColorHex: String { - cardViews.first?.debugHeaderColorHex ?? "" - } - - var debugFirstCardHeaderSurfaceColorHex: String { - cardViews.first?.debugHeaderSurfaceColorHex ?? "" - } - - var debugFirstCardHeaderSurfaceAlpha: CGFloat { - cardViews.first?.debugHeaderSurfaceAlpha ?? 0 - } - - var debugFirstCardFooterDetailText: String { - cardViews.first?.debugFooterDetailText ?? "" - } - - var debugFirstCardFooterSourceText: String { - cardViews.first?.debugFooterSourceText ?? "" - } - - var debugFirstCardFooterSourceIsHidden: Bool { - cardViews.first?.debugFooterSourceIsHidden ?? true - } - - var debugFirstCardLinkPreviewChrome: String { - cardViews.first?.debugLinkPreviewChrome ?? "" - } - - var debugFirstCardLinkPreviewHostText: String { - cardViews.first?.debugLinkPreviewHostText ?? "" - } - - var debugFirstCardLinkPreviewMonogram: String { - cardViews.first?.debugLinkPreviewMonogram ?? "" - } - - var debugFirstCardLinkPreviewAccentHex: String { - cardViews.first?.debugLinkPreviewAccentHex ?? "" - } - - var debugFirstCardLinkPreviewHeroHeight: CGFloat { - cardViews.first?.debugLinkPreviewHeroHeight ?? 0 - } - - var debugFirstCardLinkMediaPreviewChrome: String { - cardViews.first?.debugLinkMediaPreviewChrome ?? "" - } - - var debugFirstCardLinkMediaPreviewImageHeight: CGFloat { - cardViews.first?.debugLinkMediaPreviewImageHeight ?? 0 - } - - var debugFirstCardLinkMediaPreviewHostText: String { - cardViews.first?.debugLinkMediaPreviewHostText ?? "" - } - - var debugFirstCardFilePreviewChrome: String { - cardViews.first?.debugFilePreviewChrome ?? "" - } - - var debugFirstCardFilePreviewExtensionText: String { - cardViews.first?.debugFilePreviewExtensionText ?? "" - } - - var debugFirstCardFilePreviewLocationPlacement: String { - cardViews.first?.debugFilePreviewLocationPlacement ?? "" - } - - var debugFirstCardFilePreviewCoverSize: NSSize { - cardViews.first?.debugFilePreviewCoverSize ?? .zero - } - - var debugFirstCardColorPreviewChrome: String { - cardViews.first?.debugColorPreviewChrome ?? "" - } - - var debugFirstCardColorPreviewHexText: String { - cardViews.first?.debugColorPreviewHexText ?? "" - } - - var debugFirstCardColorPreviewSwatchPlacement: String { - cardViews.first?.debugColorPreviewSwatchPlacement ?? "" - } - - var debugFirstCardColorPreviewChipSize: NSSize { - cardViews.first?.debugColorPreviewChipSize ?? .zero - } - - var debugFirstCardMediaMetricText: String { - cardViews.first?.debugMediaMetricText ?? "" - } - - var debugFirstCardMediaMetricPlacement: String { - cardViews.first?.debugMediaMetricPlacement ?? "" - } - - var debugFirstCardAudioPreviewChrome: String { - cardViews.first?.debugAudioPreviewChrome ?? "" - } - - var debugFirstCardAudioArtworkSize: CGFloat { - cardViews.first?.debugAudioArtworkSize ?? 0 - } - - var debugFirstCardAudioLabelPlacement: String { - cardViews.first?.debugAudioLabelPlacement ?? "" - } - - var debugFirstCardVideoPreviewChrome: String { - cardViews.first?.debugVideoPreviewChrome ?? "" - } - - var debugFirstCardVideoFrameHeight: CGFloat { - cardViews.first?.debugVideoFrameHeight ?? 0 - } - - var debugFirstCardVideoFormatPlacement: String { - cardViews.first?.debugVideoFormatPlacement ?? "" - } - - var debugQuickPasteBadgeTexts: [String] { - cardViews.compactMap(\.debugQuickPasteBadgeText) - } - - var debugSelectedCardFrameInDocument: NSRect { - guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardItemCount else { - return .zero - } - renderCardIfNeeded(at: viewModel.selectedIndex) - guard cardView(at: viewModel.selectedIndex) != nil else { return .zero } - return cardSlotFrame(at: viewModel.selectedIndex, layout: currentCardLayout()) - } - - var debugSelectedCardFrameInPanel: NSRect { - guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardItemCount else { - return .zero - } - renderCardIfNeeded(at: viewModel.selectedIndex) - guard cardView(at: viewModel.selectedIndex) != nil else { return .zero } - return itemsStack.convert(cardSlotFrame(at: viewModel.selectedIndex, layout: currentCardLayout()), to: self) - } - - var debugSelectedCardVisualTopInset: CGFloat { - debugSelectedCardFrameInPanel.minY - } - - var debugCardRailVisibleRect: NSRect { - scrollView.contentView.bounds - } - - var debugCardRailFrameInPanel: NSRect { - scrollView.convert(scrollView.bounds, to: self) - } - - var debugCardRailTopGap: CGFloat { - bounds.maxY - scrollView.frame.maxY - } - - var debugCardRailDocumentWidth: CGFloat { - scrollView.documentView?.frame.width ?? 0 - } - - var debugCardRailDocumentHeight: CGFloat { - scrollView.documentView?.frame.height ?? 0 - } - - var debugVisibleCardPageStep: Int { - visibleCardPageStep - } - - var debugCardRailOverflowFadeVisibility: [Bool] { - scrollView.overflowFadeVisibility - } - - var debugCardRailOverflowFadeWidth: CGFloat { - scrollView.overflowFadeWidth - } - - func debugScrollCardRailVertically(deltaY: CGFloat) { - scrollView.scrollVerticallyByVerticalDelta(deltaY) - } - - var debugFirstCardMenuTitles: [String] { - cardViews.first?.debugMenuTitles ?? [] - } - - var debugFirstCardCollectionMenuTitles: [String] { - cardViews.first?.debugCollectionMenuTitles ?? [] - } - - var debugFirstCardCollectActionMenuTitles: [String] { - cardViews.first?.debugCollectActionMenuTitles ?? [] - } - - var debugFirstCardCaptureRuleMenuTitles: [String] { - cardViews.first?.debugCaptureRuleMenuTitles ?? [] - } - - var debugFirstCardVisibleActionLabels: [String] { - cardViews.first?.debugVisibleActionLabels ?? [] - } - - func debugCardVisibleActionLabels(at index: Int) -> [String] { - cardViews.first { $0.representedIndex == index }?.debugVisibleActionLabels ?? [] - } - - var debugFirstCardVisibleActionRailWidth: CGFloat { - cardViews.first?.debugVisibleActionRailWidth ?? 0 - } - - var debugConstructedActionButtonCount: Int { - cardViews.reduce(0) { $0 + $1.debugConstructedActionButtonCount } - } - - var debugFirstCardShadowOpacity: Float { - cardViews.first?.debugShadowOpacity ?? 0 - } - - var debugFirstCardShadowRadius: CGFloat { - cardViews.first?.debugShadowRadius ?? 0 - } - - var debugFirstCardLayerTranslationY: CGFloat { - cardViews.first?.debugLayerTranslationY ?? 0 - } - - var debugFirstCardSlotTopOffset: CGFloat { - cardSlots[0]?.debugCardTopOffset ?? 0 - } - - var debugSecondCardSlotTopOffset: CGFloat { - cardSlots[1]?.debugCardTopOffset ?? 0 - } - - var debugFirstCardSlotZPosition: CGFloat { - cardSlots[0]?.debugZPosition ?? 0 - } - - var debugSecondCardSlotZPosition: CGFloat { - cardSlots[1]?.debugZPosition ?? 0 - } - - var debugFirstCardFooterDetailIsHidden: Bool { - cardViews.first?.debugFooterDetailIsHidden ?? true - } - - var debugFirstCardHeaderBadgeIsHidden: Bool { - cardViews.first?.debugHeaderBadgeIsHidden ?? false - } - - var debugFirstCardHeaderBadgeFrame: NSRect { - cardViews.first?.debugHeaderBadgeFrame ?? .zero - } - - var debugFirstCardHeaderBadgeContentFrame: NSRect { - cardViews.first?.debugHeaderBadgeContentFrame ?? .zero - } - - var debugFirstCardHeaderBadgeMaskedCorners: CACornerMask { - cardViews.first?.debugHeaderBadgeMaskedCorners ?? [] - } - - var debugFirstCardHeaderBadgeCornerRadius: CGFloat { - cardViews.first?.debugHeaderBadgeCornerRadius ?? 0 - } - - var debugFirstCardHeaderBadgeShadowOpacity: Float { - cardViews.first?.debugHeaderBadgeShadowOpacity ?? 0 - } - - var debugFirstCardHeaderBadgeShadowRadius: CGFloat { - cardViews.first?.debugHeaderBadgeShadowRadius ?? 0 - } - - var debugFirstCardHeaderBadgeBorderWidth: CGFloat { - cardViews.first?.debugHeaderBadgeBorderWidth ?? 0 - } - - var debugCardObjectIdentifiers: [ObjectIdentifier] { - cardViews.map(ObjectIdentifier.init) - } - - var debugHoveredCardIndexes: [Int] { - cardViews.filter(\.debugIsHovered).map(\.representedIndex) - } - - var debugCardSelectionInputSource: String { - switch cardSelectionInputSource { - case .keyboard: - return "keyboard" - case .mouse: - return "mouse" - } - } - - var debugAnimatedCardLayoutChangeCount: Int { - animatedCardLayoutChangeCount - } - - func debugPerformFirstCardMenuItem(titled title: String) { - cardViews.first?.debugPerformMenuItem(titled: title) - } - - func debugPerformFirstCardCaptureRuleMenuItem(titled title: String) { - cardViews.first?.debugPerformCaptureRuleMenuItem(titled: title) - } - - var debugCollectionTitles: [String] { - configuredSortModes.compactMap { collectionButtons[$0]?.titleText } - } - - var debugCollectionLeadingSymbols: [String] { - configuredSortModes.compactMap { collectionButtons[$0]?.debugLeadingSymbolName } - } - - var debugCollectionChipWidths: [CGFloat] { - configuredSortModes.compactMap { collectionButtons[$0]?.debugFrameWidth } - } - - var debugCollectionChipLabelHiddenStates: [Bool] { - configuredSortModes.compactMap { collectionButtons[$0]?.debugLabelIsHidden } - } - - var debugCollectionChipCountLabelHiddenStates: [Bool] { - configuredSortModes.compactMap { collectionButtons[$0]?.debugCountLabelIsHidden } - } - - var debugCollectionStackOrientation: NSUserInterfaceLayoutOrientation { - collectionStack.orientation - } - - var debugSelectedCollectionTitle: String? { - if stackChip.isSelected { - return stackChip.titleText - } - if let custom = customCollectionButtons.first(where: { $0.value.isSelected }) { - return custom.value.titleText - } - if let mode = configuredSortModes.first(where: { viewModel.isSortModeCategorySelected($0) }) { - return collectionButtons[mode]?.titleText ?? collectionTitle(for: mode) - } - return nil - } - - var debugSelectedSortCollectionTitles: [String] { - configuredSortModes.compactMap { mode in - viewModel.isSortModeCategorySelected(mode) ? collectionTitle(for: mode) : nil - } - } - - var debugCollectionCounts: [Int] { - updateCollectionButtons() - return configuredSortModes.compactMap { collectionButtons[$0]?.count } - } - - var debugCollectionCountLabelHiddenStates: [Bool] { - updateCollectionButtons() - return configuredSortModes.compactMap { collectionButtons[$0]?.debugCountLabelIsHidden } - } - - var debugCollectionChipAccessibilityLabels: [String] { - updateCollectionButtons() - return configuredSortModes.compactMap { collectionButtons[$0]?.accessibilityLabel() } - } - - var debugCollectionChipAccessibilityHelps: [String] { - updateCollectionButtons() - return configuredSortModes.compactMap { collectionButtons[$0]?.accessibilityHelp() } - } - - var debugCollectionChipAcceptsFirstResponder: [Bool] { - configuredSortModes.compactMap { collectionButtons[$0]?.acceptsFirstResponder } - } - - var debugKeyboardFocusedCollectionTitles: [String] { - collectionChipOrder.compactMap { $0.debugIsKeyboardFocused ? $0.titleText : nil } - } - - func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool { - guard let chip = collectionButtons[mode] else { return false } - return window?.makeFirstResponder(chip) ?? false - } - - func debugFocusCustomCollectionChip(named name: String) -> Bool { - guard let chip = customCollectionButtons[name] else { return false } - return window?.makeFirstResponder(chip) ?? false - } - - func debugFocusCard(at index: Int) -> Bool { - guard index >= 0, index < cardItemCount else { return false } - renderCardIfNeeded(at: index) - guard let card = cardView(at: index) else { return false } - return window?.makeFirstResponder(card) ?? false - } - - func debugHoverCard(at index: Int) { - guard index >= 0, index < cardItemCount else { return } - renderCardIfNeeded(at: index) - hoverSelectionRequiresFreshMouseMovement = false - hoverSelectionKeyboardBarrierLocation = nil - cardView(at: index)?.debugSetHovered(true) - } - - func debugHoverCard(at index: Int, mouseLocationInPanel location: NSPoint) { - guard index >= 0, index < cardItemCount else { return } - renderCardIfNeeded(at: index) - hoverSelectionRequiresFreshMouseMovement = false - hoverSelectionKeyboardBarrierLocation = nil - cardView(at: index)?.debugSetHovered(true, mouseLocation: convert(location, to: nil)) - } - - func debugUnhoverCard(at index: Int) { - guard index >= 0, index < cardItemCount else { return } - renderCardIfNeeded(at: index) - cardView(at: index)?.debugSetHovered(false) - } - - func debugRefreshHoverCardWithoutMouseMovement(at index: Int) { - guard index >= 0, index < cardItemCount else { return } - renderCardIfNeeded(at: index) - cardView(at: index)?.debugSetHovered(true) - } - - func debugPressFocusedResponderWithReturn() { - debugPressFocusedResponder(characters: "\r", keyCode: 36) - } - - func debugPressFocusedResponderWithSpace() { - debugPressFocusedResponder(characters: " ", keyCode: 49) - } - - func debugPressFocusedResponderKeyCode(_ keyCode: UInt16) { - debugPressFocusedResponder(characters: "", keyCode: keyCode) - } - - func debugPressFocusedResponderKeyCode(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags) { - debugPressFocusedResponder(characters: "", keyCode: keyCode, modifiers: modifiers) - } - - func debugTypeFocusedResponder(_ characters: String, keyCode: UInt16) { - debugPressFocusedResponder(characters: characters, keyCode: keyCode) - } - - func debugSetSearchFieldText(_ text: String) { - searchField.stringValue = text - updateSearchText() - } - - func debugExpandSearchPresentationForSnapshot() { - searchFieldPresentationRequested = true - searchFieldCollapsedWhileIdle = false - updateSearchFieldPresentation(animated: false) - } - - private func debugPressFocusedResponder( - characters: String, - keyCode: UInt16, - modifiers: NSEvent.ModifierFlags = [] - ) { - guard let window, - let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: 0, - windowNumber: window.windowNumber, - context: nil, - characters: characters, - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: keyCode - ) else { - return - } - window.firstResponder?.keyDown(with: event) - } - - var debugCustomCollectionTitles: [String] { - viewModel.collectionNames - } - - var debugCustomCollectionCounts: [Int] { - updateCollectionButtons() - return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count } - } - - var debugCustomCollectionCountLabelHiddenStates: [Bool] { - updateCollectionButtons() - return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.debugCountLabelIsHidden } - } - - var debugCustomCollectionColorHexes: [String: String] { - Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in - (name, ClipboardCollectionVisuals.hexString(for: collectionColor(forCollectionNamed: name))) - }) - } - - var debugStackChipIsVisible: Bool { - collectionStack.arrangedSubviews.contains(stackChip) - } - - var debugStackChipCount: Int { - updateCollectionButtons() - return stackChip.count - } - - var debugStackChipIsSelected: Bool { - stackChip.isSelected - } - - var debugStackChipMenuTitles: [String] { - stackChip.debugMenuTitles - } - - var debugStackChipAccessibilityHelp: String? { - updateCollectionButtons() - return stackChip.accessibilityHelp() - } - - func debugAddVisibleClipsToStackFromStackChip() { - stackChip.debugPerformMenuItem(titled: "Add Visible Clips to Stack") - } - - func debugToggleStackCaptureFromStackChip() { - let title = viewModel.isStackCaptureEnabled ? "Stop Stack Capture" : "Start Stack Capture" - stackChip.debugPerformMenuItem(titled: title) - } - - func debugPressStackChip() { - stackChip.onPress(false) - } - - func debugMouseDownCollectionChip(_ mode: ClipboardSortMode) { - collectionButtons[mode]?.debugMouseDown() - } - - func debugHoverCollectionChip(_ mode: ClipboardSortMode) { - collectionButtons[mode]?.debugSetHovered(true) - } - - func debugUnhoverCollectionChip(_ mode: ClipboardSortMode) { - collectionButtons[mode]?.debugSetHovered(false) - } - - func debugCommandMouseDownCollectionChip(_ mode: ClipboardSortMode) { - collectionButtons[mode]?.debugCommandMouseDown() - } - - func debugMouseDownCollectionChip( - _ mode: ClipboardSortMode, - modifiers: NSEvent.ModifierFlags - ) { - collectionButtons[mode]?.debugMouseDown(modifiers: modifiers) - } - - func debugCollectionChipBackgroundAlpha(_ mode: ClipboardSortMode) -> CGFloat { - collectionButtons[mode]?.debugBackgroundAlpha ?? 0 - } - - var debugClearHistoryMenuTitles: [String] { - clearHistoryMenu().items.map { $0.title } - } - - func debugPerformClearHistoryMenuItem(titled title: String) { - let menu = clearHistoryMenu() - guard let item = menu.items.first(where: { $0.title == title }) else { return } - _ = item.target?.perform(item.action, with: item) - } - - func debugMouseDownCustomCollectionChip(named name: String) { - customCollectionButtons[name]?.debugMouseDown() - } - - func debugHoverCustomCollectionChip(named name: String) { - customCollectionButtons[name]?.debugSetHovered(true) - } - - func debugUnhoverCustomCollectionChip(named name: String) { - customCollectionButtons[name]?.debugSetHovered(false) - } - - func debugCommandMouseDownCustomCollectionChip(named name: String) { - customCollectionButtons[name]?.debugCommandMouseDown() - } - - var debugCollectionRailVisibleWidth: CGFloat { - collectionScrollView.contentView.bounds.width - } - - var debugCollectionRailFrameInPanel: NSRect { - collectionScrollView.convert(collectionScrollView.bounds, to: self) - } - - var debugCollectionRailHasHorizontalScroller: Bool { - collectionScrollView.hasHorizontalScroller - } - - var debugCollectionRailHeight: CGFloat { - collectionScrollView.frame.height - } - - var debugCollectionRailDocumentWidth: CGFloat { - collectionScrollView.documentView?.frame.width ?? 0 - } - - var debugCollectionRailDocumentMinX: CGFloat { - collectionScrollView.documentView?.frame.minX ?? 0 - } - - var debugCollectionRailVisibleRect: NSRect { - collectionScrollView.contentView.bounds - } - - var debugCollectionRailContentTopInset: CGFloat { - collectionStack.frame.minY - } - - var debugCollectionRailContentFrameInPanel: NSRect { - collectionStack.convert(collectionStack.bounds, to: self) - } - - var debugCollectionRailOverflowFadeVisibility: [Bool] { - collectionScrollView.overflowFadeVisibility - } - - var debugCollectionRailOverflowFadeWidth: CGFloat { - collectionScrollView.overflowFadeWidth - } - - func debugScrollCollectionRailVertically(deltaY: CGFloat) { - collectionScrollView.scrollVerticallyByVerticalDelta(deltaY) - } - - var debugEmptyStateText: (title: String, detail: String)? { - emptyStateText - } - - var debugAddCollectionButtonIsEnabled: Bool { - addCollectionButton.isEnabled - } - - var debugCollectionRailContainsAddButton: Bool { - collectionStack.arrangedSubviews.contains(addCollectionButton) - } - - func debugSetCollectionNameProvider(_ provider: @escaping () -> String?) { - collectionNameProviderForTesting = provider - } - - func debugSuppressClipEditDialogs(_ suppress: Bool = true) { - suppressClipEditDialogsForTesting = suppress - } - - func debugSuppressClipOpenActions(_ suppress: Bool = true) { - suppressClipOpenActionsForTesting = suppress - } - - var debugRenameClipRequestCount: Int { - renameClipRequestCountForTesting - } - - var debugEditClipRequestCount: Int { - editClipRequestCountForTesting - } - - var debugOpenClipRequestCount: Int { - openClipRequestCountForTesting - } - - var debugShowInClipboardRequestCount: Int { - showInClipboardRequestCountForTesting - } - - func debugPressAddCollectionButton() { - createCollectionFromToolbar() - } - - func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] { - customCollectionButtons[collectionName]?.debugMenuTitles ?? [] - } - - func debugCustomCollectionAccessibilityHelp(named collectionName: String) -> String? { - updateCollectionButtons() - return customCollectionButtons[collectionName]?.accessibilityHelp() - } - - func debugEditCollection(named collectionName: String, to newName: String, colorHex: String) { - viewModel.updateCollection(named: collectionName, to: newName, colorHex: colorHex) - focusSelectedCollectionChip() - } - - func debugDeleteCollection(named collectionName: String) { - viewModel.deleteCollection(named: collectionName) - focusSelectedCollectionChip() - } - - func debugRenameFirstCard(to title: String) { - viewModel.selectItem(at: 0) - viewModel.updateSelectedTitle(to: title) - } - - func debugShowFirstCardInClipboard() { - showSelectedInClipboard(at: 0) - } - - var debugSearchFieldText: String { - searchField.stringValue - } - - var debugSearchFieldWidth: CGFloat { - searchFieldWidthConstraint?.constant ?? searchControlContainer.frame.width - } - - var debugSearchControlHeight: CGFloat { - searchControlContainer.frame.height - } - - var debugSearchIconButtonHeight: CGFloat { - min(searchIconButton.frame.height, searchControlContainer.bounds.height) - } - - var debugSearchIconButtonCornerRadius: CGFloat { - searchIconButton.layer?.cornerRadius ?? 0 - } - - var debugSearchGlassMaterial: NSVisualEffectView.Material { - searchControlContainer.material - } - - var debugSearchGlassBlendingMode: NSVisualEffectView.BlendingMode { - searchControlContainer.blendingMode - } - - var debugSearchGlassCornerRadius: CGFloat { - searchControlContainer.layer?.cornerRadius ?? 0 - } - - var debugSearchGlassBorderAlpha: CGFloat { - Self.debugAlpha(searchControlContainer.layer?.borderColor) - } - - var debugSearchGlassTintAlpha: CGFloat { - Self.debugAlpha(searchControlContainer.layer?.backgroundColor) - } - - var debugSearchFieldUsesTransparentChrome: Bool { - !searchField.isBezeled && !searchField.drawsBackground - } - - var debugSearchIconIsPinnedToLeadingEdge: Bool { - abs(searchIconButton.frame.minX - searchControlContainer.bounds.minX) <= 0.5 - && abs(searchIconButton.frame.midY - searchControlContainer.bounds.midY) <= 0.5 - } - - var debugSearchIconLeadingOffset: NSPoint { - NSPoint( - x: searchIconButton.frame.minX - searchControlContainer.bounds.minX, - y: searchIconButton.frame.midY - searchControlContainer.bounds.midY - ) - } - - var debugSearchFieldIsCenteredInGlass: Bool { - abs(searchField.frame.midY - searchControlContainer.bounds.midY) < 0.5 - } - - var debugSearchControlIsLeadingAlignedInToolbar: Bool { - guard let shelfChromeView else { return false } - return abs(searchControlContainer.frame.minX - shelfChromeView.bounds.minX) <= 0.5 - } - - var debugSearchControlToolbarLeadingOffset: CGFloat { - guard let shelfChromeView else { return .infinity } - return searchControlContainer.frame.minX - shelfChromeView.bounds.minX - } - - var debugSearchTextLeadingGap: CGFloat { - guard let cell = searchField.cell as? NSSearchFieldCell else { return -.infinity } - let textRect = cell.searchTextRect(forBounds: searchField.bounds) - let textMinX = searchField.frame.minX + textRect.minX - return textMinX - searchIconButton.frame.maxX - } - - var debugSearchEditorLeadingGap: CGFloat { - guard let editor = searchField.currentEditor() else { return -.infinity } - let editorFrame = searchControlContainer.convert(editor.bounds, from: editor) - return editorFrame.minX - searchIconButton.frame.maxX - } - - var debugSearchControlTrailingActionGap: CGFloat { - guard let utilityToolbarGroup else { return -.infinity } - return utilityToolbarGroup.frame.minX - searchControlContainer.frame.maxX - } - - var debugSearchAnimationDuration: TimeInterval { - Motion.searchFieldResizeDuration - } - - var debugCardExpansionAnimationDuration: TimeInterval { - Motion.cardExpansionDuration - } - - var debugSearchFieldSharesAnimatedContainerWithIcon: Bool { - searchField.superview === searchControlContainer - && searchIconButton.superview === searchControlContainer - && searchControlContainer.superview === shelfChromeView - && searchField.superview !== shelfChromeView - && searchIconButton.superview !== shelfChromeView - } - - var debugSearchFieldPlaceholderText: String { - searchField.placeholderAttributedString?.string ?? searchField.placeholderString ?? "" - } - - var debugSearchFieldIsVisible: Bool { - !searchField.isHidden - } - - var debugSearchIconButtonIsVisible: Bool { - !searchIconButton.isHidden - } - - var debugShelfChromeRowCount: Int { - headerStack?.arrangedSubviews.count ?? 0 - } - - var debugShelfChromeContainsSearchAndCollections: Bool { - guard let shelfChromeView else { return false } - return searchControlContainer.superview === shelfChromeView - && collectionScrollView.superview === shelfChromeView - } - - var debugShelfChromeContainsSearchAndActions: Bool { - guard let shelfChromeView else { return false } - return searchControlContainer.superview === shelfChromeView - && utilityToolbarGroup?.superview === shelfChromeView - && collectionScrollView.superview !== shelfChromeView - } - - var debugCollectionRailIsBesideCardRail: Bool { - collectionScrollView.superview === contentStack && scrollView.superview === contentStack - } - - func debugDropFirstCard(onCollectionNamed collectionName: String) { - guard let itemID = cardViews.first?.debugItemID else { return } - customCollectionButtons[collectionName]?.debugDropItem(itemID) - } - - private static func debugAlpha(_ cgColor: CGColor?) -> CGFloat { - guard let cgColor, let color = NSColor(cgColor: cgColor) else { return 0 } - return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent - } - - var debugCustomCollectionDropTargets: [String] { - viewModel.collectionNames.filter { customCollectionButtons[$0]?.debugAcceptsItemDrops == true } - } - - #endif func controlTextDidChange(_ notification: Notification) { guard notification.object as? NSSearchField === searchField else { return } @@ -3757,9 +2597,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func showSelectedInClipboard(at index: Int) { - #if DEBUG - showInClipboardRequestCountForTesting += 1 - #endif viewModel.selectItem(at: index) viewModel.showSelectedInClipboard() syncSearchFieldFromViewModel() @@ -3919,17 +2756,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func requestCollectionCreation() -> CollectionCreationRequest? { - #if DEBUG - if let collectionNameProviderForTesting { - guard let name = ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting()) else { - return nil - } - return CollectionCreationRequest( - name: name, - colorHex: ClipboardCollectionVisuals.defaultColorHex(forCollectionNamed: name) - ) - } - #endif return requestCollectionDetails( title: "New Collection", @@ -4104,23 +2930,6 @@ private final class HorizontalRailScrollView: NSScrollView { onVisibleBoundsChanged?() } - func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) { - scrollHorizontally(by: -deltaY) - } - - func scrollVerticallyByVerticalDelta(_ deltaY: CGFloat) { - scrollVertically(by: -deltaY) - } - - var overflowFadeVisibility: [Bool] { - updateOverflowFades() - return [!leadingFade.isHidden, !trailingFade.isHidden] - } - - var overflowFadeWidth: CGFloat { - fadeWidth - } - private func configureOverflowFades() { leadingFade.translatesAutoresizingMaskIntoConstraints = false trailingFade.translatesAutoresizingMaskIntoConstraints = false @@ -4149,11 +2958,6 @@ private final class HorizontalRailScrollView: NSScrollView { return max(0, documentView.frame.width - contentView.bounds.width) } - private var maxVerticalOffset: CGFloat { - guard let documentView else { return 0 } - return max(0, documentView.frame.height - contentView.bounds.height) - } - private func scrollHorizontally(by deltaX: CGFloat) { let maxOffset = maxHorizontalOffset guard maxOffset > 0 else { return } @@ -4165,17 +2969,6 @@ private final class HorizontalRailScrollView: NSScrollView { reflectScrolledClipView(contentView) } - private func scrollVertically(by deltaY: CGFloat) { - let maxOffset = maxVerticalOffset - guard maxOffset > 0 else { return } - let origin = contentView.bounds.origin - let targetY = min(max(origin.y + deltaY, 0), maxOffset) - guard targetY != origin.y else { return } - - contentView.scroll(to: NSPoint(x: origin.x, y: targetY)) - reflectScrolledClipView(contentView) - } - private func updateOverflowFades() { let maxOffset = maxHorizontalOffset guard maxOffset > 0 else { @@ -4270,6 +3063,19 @@ private final class CollectionChipView: NSView { static let iconOnlySize: CGFloat = 34 } + private enum MenuCommand: Int { + case toggleStackCapture + case addVisibleToStack + case pasteStackNext + case copyStackNext + case pasteStackText + case copyStackText + case clearStack + case edit + case export + case delete + } + let titleText: String private let color: NSColor private let symbolName: String? @@ -4622,9 +3428,11 @@ private final class CollectionChipView: NSView { let menu = NSMenu(title: titleText) menu.autoenablesItems = false if onToggleStackCapture != nil { - let item = NSMenuItem(title: isStackCaptureActive ? "Stop Stack Capture" : "Start Stack Capture", action: #selector(toggleStackCaptureFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem( + isStackCaptureActive ? "Stop Stack Capture" : "Start Stack Capture", + command: .toggleStackCapture, + to: menu + ) } if onToggleStackCapture != nil && ( onAddVisibleToStack != nil @@ -4637,97 +3445,62 @@ private final class CollectionChipView: NSView { menu.addItem(NSMenuItem.separator()) } if onAddVisibleToStack != nil { - let item = NSMenuItem(title: "Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Add Visible Clips to Stack", command: .addVisibleToStack, to: menu) } if onPasteStackNext != nil { - let item = NSMenuItem(title: "Paste Stack Next", action: #selector(pasteStackNextFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Paste Stack Next", command: .pasteStackNext, to: menu) } if onCopyStackNext != nil { - let item = NSMenuItem(title: "Copy Stack Next", action: #selector(copyStackNextFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Copy Stack Next", command: .copyStackNext, to: menu) } if onPasteStackText != nil { - let item = NSMenuItem(title: "Paste Stack as Text", action: #selector(pasteStackTextFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Paste Stack as Text", command: .pasteStackText, to: menu) } if onCopyStackText != nil { - let item = NSMenuItem(title: "Copy Stack as Text", action: #selector(copyStackTextFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Copy Stack as Text", command: .copyStackText, to: menu) } if onClearStack != nil { - let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Clear Stack", command: .clearStack, to: menu) } if (onEdit != nil || onExport != nil || onDelete != nil) && !menu.items.isEmpty { menu.addItem(NSMenuItem.separator()) } if onEdit != nil { - let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Edit Collection...", command: .edit, to: menu) } if onExport != nil { - let item = NSMenuItem(title: "Export Pinboard...", action: #selector(exportFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Export Pinboard...", command: .export, to: menu) } if onDelete != nil { if !menu.items.isEmpty { menu.addItem(NSMenuItem.separator()) } - let item = NSMenuItem(title: "Delete Collection", action: #selector(deleteFromMenu), keyEquivalent: "") - item.target = self - menu.addItem(item) + addMenuItem("Delete Collection", command: .delete, to: menu) } return menu } - @objc private func addVisibleToStackFromMenu() { - onAddVisibleToStack?() + private func addMenuItem(_ title: String, command: MenuCommand, to menu: NSMenu) { + let item = NSMenuItem(title: title, action: #selector(performMenuCommand(_:)), keyEquivalent: "") + item.target = self + item.tag = command.rawValue + menu.addItem(item) } - @objc private func pasteStackNextFromMenu() { - onPasteStackNext?() - } - - @objc private func copyStackNextFromMenu() { - onCopyStackNext?() - } - - @objc private func pasteStackTextFromMenu() { - onPasteStackText?() - } - - @objc private func copyStackTextFromMenu() { - onCopyStackText?() - } - - @objc private func clearStackFromMenu() { - onClearStack?() - } - - @objc private func toggleStackCaptureFromMenu() { - onToggleStackCapture?() - } - - @objc private func editFromMenu() { - onEdit?() - } - - @objc private func exportFromMenu() { - onExport?() - } - - @objc private func deleteFromMenu() { - onDelete?() + @objc private func performMenuCommand(_ sender: NSMenuItem) { + guard let command = MenuCommand(rawValue: sender.tag) else { return } + switch command { + case .toggleStackCapture: onToggleStackCapture?() + case .addVisibleToStack: onAddVisibleToStack?() + case .pasteStackNext: onPasteStackNext?() + case .copyStackNext: onCopyStackNext?() + case .pasteStackText: onPasteStackText?() + case .copyStackText: onCopyStackText?() + case .clearStack: onClearStack?() + case .edit: onEdit?() + case .export: onExport?() + case .delete: onDelete?() + } } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -4774,69 +3547,6 @@ private final class CollectionChipView: NSView { .flatMap(UUID.init(uuidString:)) } - #if DEBUG - var debugAcceptsItemDrops: Bool { - onDropItem != nil - } - - var debugIsKeyboardFocused: Bool { - isKeyboardFocused - } - - var debugCountLabelIsHidden: Bool { - countLabel.isHidden - } - - var debugLabelIsHidden: Bool { - label.isHidden - } - - var debugLeadingSymbolName: String { - symbolName ?? "" - } - - var debugFrameWidth: CGFloat { - frame.width - } - - var debugBackgroundAlpha: CGFloat { - guard let backgroundColor = layer?.backgroundColor, - let color = NSColor(cgColor: backgroundColor) else { - return 0 - } - return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent - } - - func debugDropItem(_ itemID: UUID) { - onDropItem?(itemID) - } - - func debugMouseDown() { - activateFromUserInteraction(extending: false) - } - - func debugCommandMouseDown() { - activateFromUserInteraction(extending: true) - } - - func debugMouseDown(modifiers: NSEvent.ModifierFlags) { - activateFromUserInteraction(extending: Self.shouldExtendSelection(for: modifiers)) - } - - func debugSetHovered(_ hovered: Bool) { - setHoveredForPresentation(hovered) - onHover(hovered) - } - - var debugMenuTitles: [String] { - contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } - } - - func debugPerformMenuItem(titled title: String) { - guard let item = contextMenu().items.first(where: { $0.title == title }) else { return } - _ = item.target?.perform(item.action, with: item) - } - #endif } private final class AspectFillImageView: NSView { @@ -5007,26 +3717,43 @@ private final class ClipboardItemCardSlotView: NSView { CGFloat(layer?.presentation()?.zPosition ?? layer?.zPosition ?? 0) } - #if DEBUG - var debugPresentationFrame: NSRect { - visualFrame - } - - var debugCardTopOffset: CGFloat { - topConstraint?.constant ?? inactiveTopOffset - } - - var debugZPosition: CGFloat { - CGFloat(layer?.zPosition ?? 0) - } - - #endif } private final class ClipboardItemCardView: NSView, NSDraggingSource { private enum Metrics { static let dragThreshold: CGFloat = 4 } + private enum MenuCommand: Int { + case paste + case copy + case pastePlainText + case copyPlainText + case pasteSelectionText + case copySelectionText + case addSelectionToStack + case showInClipboard + case rename + case toggleStack + case addVisibleToStack + case pasteStackNext + case copyStackNext + case pasteStackText + case copyStackText + case clearStack + case editText + case rotateImage + case extractImageText + case preview + case togglePin + case open + case reveal + case assignToCollection + case createCollection + case removeFromCollection + case ignoreSourceApp + case ignoreKind + case delete + } private enum Palette { static let border = NSColor.white.withAlphaComponent(0.42).cgColor static let selectedBorder = NSColor.white.withAlphaComponent(0.74).cgColor @@ -5295,9 +4022,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { expandedDetailHeightConstraint?.constant = 0 superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - #if DEBUG - debugAnimatedPresentationChangeCount += 1 - #endif NSAnimationContext.runAnimationGroup { context in context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming @@ -5322,9 +4046,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { expandedDetailHeightConstraint?.constant = expandedDetailHeight superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - #if DEBUG - debugAnimatedPresentationChangeCount += 1 - #endif NSAnimationContext.runAnimationGroup { context in context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming @@ -5346,9 +4067,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - #if DEBUG - debugAnimatedPresentationChangeCount += 1 - #endif NSAnimationContext.runAnimationGroup { context in context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming @@ -5370,9 +4088,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - #if DEBUG - debugAnimatedPresentationChangeCount += 1 - #endif NSAnimationContext.runAnimationGroup { context in context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming @@ -5645,317 +4360,56 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return contextMenu() } - #if DEBUG - private(set) var debugPreviewSummary = "" - private(set) var debugPreviewStyle = "" - private(set) var debugAnimatedPresentationChangeCount = 0 - private(set) var debugHeaderBadgeSymbol = "" - private(set) var debugHeaderBadgeText = "" - private(set) var debugHeaderTitle = "" - private(set) var debugHeaderSubtitle = "" - private(set) var debugHeaderColorHex = "" - private(set) var debugTextPreviewTitle = "" - private(set) var debugTextPreviewBody = "" - private(set) var debugTextPreviewChrome = "" - private(set) var debugTextPreviewAccentHex = "" - private(set) var debugTextPreviewFadePlacement = "" - private(set) var debugLinkPreviewChrome = "" - private(set) var debugLinkPreviewHostText = "" - private(set) var debugLinkPreviewMonogram = "" - private(set) var debugLinkPreviewAccentHex = "" - private(set) var debugLinkPreviewHeroHeight: CGFloat = 0 - private(set) var debugLinkMediaPreviewChrome = "" - private(set) var debugLinkMediaPreviewImageHeight: CGFloat = 0 - private(set) var debugLinkMediaPreviewHostText = "" - private(set) var debugFilePreviewChrome = "" - private(set) var debugFilePreviewExtensionText = "" - private(set) var debugFilePreviewLocationPlacement = "" - private(set) var debugFilePreviewCoverSize = NSSize.zero - private(set) var debugColorPreviewChrome = "" - private(set) var debugColorPreviewHexText = "" - private(set) var debugColorPreviewSwatchPlacement = "" - private(set) var debugColorPreviewChipSize = NSSize.zero - private(set) var debugMediaMetricText = "" - private(set) var debugMediaMetricPlacement = "" - private(set) var debugAudioPreviewChrome = "" - private(set) var debugAudioArtworkSize: CGFloat = 0 - private(set) var debugAudioLabelPlacement = "" - private(set) var debugVideoPreviewChrome = "" - private(set) var debugVideoFrameHeight: CGFloat = 0 - private(set) var debugVideoFormatPlacement = "" - - var debugMenuTitles: [String] { - contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } - } - - var debugCollectionMenuTitles: [String] { - guard let collectionMenu = contextMenu().items.first(where: { $0.title == "Add to Collection" })?.submenu else { - return [] - } - return collectionMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } - } - - var debugCollectActionMenuTitles: [String] { - collectionAssignmentMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } - } - - var debugCaptureRuleMenuTitles: [String] { - guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu else { - return [] - } - return rulesMenu.items.map { $0.isSeparatorItem ? "-" : $0.title } - } - - var debugVisibleActionLabels: [String] { - [] - } - - var debugVisibleActionRailWidth: CGFloat { - 0 - } - - var debugActionRailAlpha: CGFloat { - 0 - } - - var debugConstructedActionButtonCount: Int { - 0 - } - - var debugShadowOpacity: Float { - layer?.shadowOpacity ?? 0 - } - - var debugShadowRadius: CGFloat { - layer?.shadowRadius ?? 0 - } - - var debugLayerTranslationY: CGFloat { - layer.map { CGFloat($0.transform.m42) } ?? 0 - } - - var debugFooterDetailIsHidden: Bool { - footerDetailLabel.isHidden - } - - var debugHeaderBadgeIsHidden: Bool { - headerBadgeView?.isHidden ?? false - } - - var debugHeaderBadgeFrame: NSRect { - guard let headerBadgeView else { return .zero } - return headerBadgeView.convert(headerBadgeView.bounds, to: self) - } - - var debugHeaderBadgeContentFrame: NSRect { - guard let headerBadgeContentView else { return .zero } - return headerBadgeContentView.convert(headerBadgeContentView.bounds, to: self) - } - - var debugCompactMetricText: String { - compactMetricLabel?.stringValue ?? "" - } - - var debugCompactMetricIsHidden: Bool { - compactMetricLabel?.isHidden ?? true - } - - var debugCompactMetricFrame: NSRect { - guard let compactMetricLabel else { return .zero } - return compactMetricLabel.convert(compactMetricLabel.bounds, to: self) - } - - var debugCompactMetricFittingWidth: CGFloat { - compactMetricLabel?.fittingSize.width ?? 0 - } - - var debugHeaderBadgeMaskedCorners: CACornerMask { - headerBadgeView?.layer?.maskedCorners ?? [] - } - - var debugHeaderFrame: NSRect { - guard let compactContentView else { return .zero } - return compactContentView.convert(compactContentView.bounds, to: self) - } - - var debugHeaderMaskedCorners: CACornerMask { - compactContentView?.layer?.maskedCorners ?? [] - } - - var debugHeaderSurfaceColorHex: String { - guard let backgroundColor = compactContentView?.layer?.backgroundColor, - let color = NSColor(cgColor: backgroundColor) else { - return "" - } - return Self.debugHex(color) - } - - var debugHeaderSurfaceAlpha: CGFloat { - guard let backgroundColor = compactContentView?.layer?.backgroundColor, - let color = NSColor(cgColor: backgroundColor) else { - return 0 - } - return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent - } - - var debugExpandedDetailFrame: NSRect { - guard let fullContentView else { return .zero } - return fullContentView.convert(fullContentView.bounds, to: self) - } - - var debugExpandedDetailPresentationHeight: CGFloat { - guard let fullContentView else { return 0 } - return fullContentView.layer?.presentation()?.bounds.height ?? fullContentView.bounds.height - } - - var debugContentCornerRadius: CGFloat { - contentView.layer?.cornerRadius ?? 0 - } - - var debugContentBackgroundAlpha: CGFloat { - guard let backgroundColor = contentView.layer?.backgroundColor, - let color = NSColor(cgColor: backgroundColor) else { - return 0 - } - return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent - } - - var debugHeaderBadgeCornerRadius: CGFloat { - headerBadgeView?.layer?.cornerRadius ?? 0 - } - - var debugHeaderBadgeShadowOpacity: Float { - headerBadgeView?.layer?.shadowOpacity ?? 0 - } - - var debugHeaderBadgeShadowRadius: CGFloat { - headerBadgeView?.layer?.shadowRadius ?? 0 - } - - var debugHeaderBadgeBorderWidth: CGFloat { - headerBadgeView?.layer?.borderWidth ?? 0 - } - - func debugPerformMenuItem(titled title: String) { - guard let item = contextMenu().items.first(where: { $0.title == title }) else { return } - _ = item.target?.perform(item.action, with: item) - } - - func debugPerformCaptureRuleMenuItem(titled title: String) { - guard let rulesMenu = contextMenu().items.first(where: { $0.title == "Capture Rules" })?.submenu, - let item = rulesMenu.items.first(where: { $0.title == title }) else { - return - } - _ = item.target?.perform(item.action, with: item) - } - - var debugQuickPasteBadgeText: String? { - quickPasteBadgeLabel?.stringValue - } - - var debugIsKeyboardFocused: Bool { - isKeyboardFocused - } - - var debugIsSelected: Bool { - isSelected - } - - var debugIsActiveSelection: Bool { - isActiveSelection - } - - var debugIsHovered: Bool { - isHovered - } - - var debugPresentation: String { - switch presentation { - case .verticalRow: - return "vertical-row" - case .verticalFocus: - return "vertical-focus" - } - } - - func debugSetHovered(_ hovered: Bool, mouseLocation: NSPoint? = nil) { - setHoverState(hovered, notifySelection: hovered, mouseLocation: mouseLocation) - } - - var debugBorderWidth: CGFloat { - contentView.layer?.borderWidth ?? 0 - } - - var debugFooterDetailText: String { - footerDetailLabel.stringValue - } - - var debugFooterSourceText: String { - footerSourceLabel.stringValue - } - - var debugFooterSourceIsHidden: Bool { - footerSourceLabel.isHidden - } - - var debugItemID: UUID { - itemID - } - - static func debugHex(_ color: NSColor) -> String { - ClipboardCollectionVisuals.hexString(for: color) - } - #endif private func contextMenu() -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false - addMenuItem(selectedGroupCount > 1 ? "Paste Selection" : "Paste", action: #selector(pasteFromMenu), to: menu) - addMenuItem(selectedGroupCount > 1 ? "Copy Selection" : "Copy", action: #selector(copyFromMenu), to: menu) + addMenuItem(selectedGroupCount > 1 ? "Paste Selection" : "Paste", command: .paste, to: menu) + addMenuItem(selectedGroupCount > 1 ? "Copy Selection" : "Copy", command: .copy, to: menu) if canPlainText { - addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu) - addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), to: menu) + addMenuItem("Paste Plain Text", command: .pastePlainText, to: menu) + addMenuItem("Copy Plain Text", command: .copyPlainText, to: menu) } if selectedGroupCount > 1 { - addMenuItem("Paste Selection as Text", action: #selector(pasteSelectionTextFromMenu), to: menu) - addMenuItem("Copy Selection as Text", action: #selector(copySelectionTextFromMenu), to: menu) - addMenuItem("Add Selection to Stack", action: #selector(addSelectionToStackFromMenu), to: menu) + addMenuItem("Paste Selection as Text", command: .pasteSelectionText, to: menu) + addMenuItem("Copy Selection as Text", command: .copySelectionText, to: menu) + addMenuItem("Add Selection to Stack", command: .addSelectionToStack, to: menu) } if canShowInClipboard { - addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu) + addMenuItem("Show in Clipboard", command: .showInClipboard, to: menu) } - addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu) - addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu) - addMenuItem("Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), to: menu) + addMenuItem("Rename...", command: .rename, to: menu) + addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", command: .toggleStack, to: menu) + addMenuItem("Add Visible Clips to Stack", command: .addVisibleToStack, to: menu) if stackCount > 0 { - addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu) - addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu) - addMenuItem("Paste Stack as Text", action: #selector(pasteStackTextFromMenu), to: menu) - addMenuItem("Copy Stack as Text", action: #selector(copyStackTextFromMenu), to: menu) - addMenuItem("Clear Stack", action: #selector(clearStackFromMenu), to: menu) + addMenuItem("Paste Stack Next", command: .pasteStackNext, to: menu) + addMenuItem("Copy Stack Next", command: .copyStackNext, to: menu) + addMenuItem("Paste Stack as Text", command: .pasteStackText, to: menu) + addMenuItem("Copy Stack as Text", command: .copyStackText, to: menu) + addMenuItem("Clear Stack", command: .clearStack, to: menu) } if canEditText { - addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu) + addMenuItem("Edit", command: .editText, to: menu) } if canRotateImage { - addMenuItem("Rotate Image", action: #selector(rotateImageFromMenu), to: menu) + addMenuItem("Rotate Image", command: .rotateImage, to: menu) } if canExtractImageText { - addMenuItem("Extract Text", action: #selector(extractImageTextFromMenu), to: menu) + addMenuItem("Extract Text", command: .extractImageText, to: menu) } if canPreview { - addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu) + addMenuItem("Quick Look", command: .preview, to: menu) } - addMenuItem(itemIsPinned ? "Unpin" : "Pin", action: #selector(togglePinFromMenu), to: menu) + addMenuItem(itemIsPinned ? "Unpin" : "Pin", command: .togglePin, to: menu) addCollectionMenu(to: menu) addCaptureRulesMenu(to: menu) menu.addItem(NSMenuItem.separator()) - let open = addMenuItem("Open", action: #selector(openFromMenu), to: menu) + let open = addMenuItem("Open", command: .open, to: menu) open.isEnabled = canOpen - let reveal = addMenuItem("Reveal in Finder", action: #selector(revealFromMenu), to: menu) + let reveal = addMenuItem("Reveal in Finder", command: .reveal, to: menu) reveal.isEnabled = canReveal menu.addItem(NSMenuItem.separator()) - addMenuItem("Delete", action: #selector(deleteFromMenu), to: menu) + addMenuItem("Delete", command: .delete, to: menu) return menu } @@ -5971,25 +4425,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { submenu.autoenablesItems = false for name in availableCollectionNames() { - let item = NSMenuItem(title: name, action: #selector(assignToCollectionFromMenu(_:)), keyEquivalent: "") - item.target = self + let item = addMenuItem(name, command: .assignToCollection, to: submenu) item.representedObject = name if itemCollectionName == name { item.state = .on } - submenu.addItem(item) } submenu.addItem(NSMenuItem.separator()) - let newCollection = NSMenuItem(title: "New Collection...", action: #selector(createCollectionFromMenu), keyEquivalent: "") - newCollection.target = self - submenu.addItem(newCollection) + addMenuItem("New Collection...", command: .createCollection, to: submenu) if itemCollectionName?.clipboardTrimmed.isEmpty == false { submenu.addItem(NSMenuItem.separator()) - let remove = NSMenuItem(title: "Remove from Collection", action: #selector(removeFromCollectionFromMenu), keyEquivalent: "") - remove.target = self - submenu.addItem(remove) + addMenuItem("Remove from Collection", command: .removeFromCollection, to: submenu) } return submenu @@ -6000,23 +4448,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let submenu = NSMenu(title: "Capture Rules") submenu.autoenablesItems = false - let ignoreSource = NSMenuItem( - title: ignoreSourceTitle(), - action: #selector(ignoreSourceAppFromMenu), - keyEquivalent: "" - ) - ignoreSource.target = self + let ignoreSource = addMenuItem(ignoreSourceTitle(), command: .ignoreSourceApp, to: submenu) ignoreSource.isEnabled = itemSourceAppName != nil || itemSourceAppBundleID != nil - submenu.addItem(ignoreSource) - let ignoreKind = NSMenuItem( - title: "Ignore \(kindLabel(for: itemKind)) Items", - action: #selector(ignoreKindFromMenu), - keyEquivalent: "" + addMenuItem( + "Ignore \(kindLabel(for: itemKind)) Items", + command: .ignoreKind, + to: submenu ) - ignoreKind.target = self - ignoreKind.isEnabled = true - submenu.addItem(ignoreKind) menu.addItem(parent) menu.setSubmenu(submenu, for: parent) @@ -6061,9 +4500,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } @discardableResult - private func addMenuItem(_ title: String, action: Selector, to menu: NSMenu) -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + private func addMenuItem(_ title: String, command: MenuCommand, to menu: NSMenu) -> NSMenuItem { + let item = NSMenuItem(title: title, action: #selector(performMenuCommand(_:)), keyEquivalent: "") item.target = self + item.tag = command.rawValue item.isEnabled = true menu.addItem(item) return item @@ -6160,107 +4600,51 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { footerDetailLabel.isHidden = false } - @objc private func pasteFromMenu() { - onPaste(index) + @objc private func performMenuCommand(_ sender: NSMenuItem) { + guard let command = MenuCommand(rawValue: sender.tag) else { return } + switch command { + case .paste: onPaste(index) + case .copy: onCopy(index) + case .pastePlainText: onPastePlainText(index) + case .copyPlainText: onCopyPlainText(index) + case .pasteSelectionText: onPasteSelectionText(index) + case .copySelectionText: onCopySelectionText(index) + case .addSelectionToStack: onAddSelectionToStack(index) + case .showInClipboard: onShowInClipboard(index) + case .rename: onRename(index) + case .toggleStack: onToggleStack(index) + case .addVisibleToStack: onAddVisibleToStack() + case .pasteStackNext: onPasteStackNext() + case .copyStackNext: onCopyStackNext() + case .pasteStackText: onPasteStackText() + case .copyStackText: onCopyStackText() + case .clearStack: onClearStack() + case .editText: onEditText(index) + case .rotateImage: onRotateImage(index) + case .extractImageText: onExtractImageText(index) + case .preview: onPreview(index) + case .togglePin: onTogglePin(index) + case .open: onOpen(index) + case .reveal: onReveal(index) + case .assignToCollection: + guard let name = sender.representedObject as? String else { return } + onAssignCollection(index, name) + case .createCollection: + createCollection() + case .removeFromCollection: + onAssignCollection(index, nil) + case .ignoreSourceApp: + onIgnoreSourceApp(index) + case .ignoreKind: + onIgnoreKind(index) + case .delete: + onDelete(index) + } } - @objc private func copyFromMenu() { - onCopy(index) - } - - @objc private func pastePlainTextFromMenu() { - onPastePlainText(index) - } - - @objc private func copyPlainTextFromMenu() { - onCopyPlainText(index) - } - - @objc private func pasteSelectionTextFromMenu() { - onPasteSelectionText(index) - } - - @objc private func copySelectionTextFromMenu() { - onCopySelectionText(index) - } - - @objc private func addSelectionToStackFromMenu() { - onAddSelectionToStack(index) - } - - @objc private func toggleStackFromMenu() { - onToggleStack(index) - } - - @objc private func addVisibleToStackFromMenu() { - onAddVisibleToStack() - } - - @objc private func pasteStackNextFromMenu() { - onPasteStackNext() - } - - @objc private func copyStackNextFromMenu() { - onCopyStackNext() - } - - @objc private func pasteStackTextFromMenu() { - onPasteStackText() - } - - @objc private func copyStackTextFromMenu() { - onCopyStackText() - } - - @objc private func clearStackFromMenu() { - onClearStack() - } - - @objc private func showInClipboardFromMenu() { - onShowInClipboard(index) - } - - @objc private func renameFromMenu() { - onRename(index) - } - - @objc private func editTextFromMenu() { - onEditText(index) - } - - @objc private func rotateImageFromMenu() { - onRotateImage(index) - } - - @objc private func extractImageTextFromMenu() { - onExtractImageText(index) - } - - @objc private func previewFromMenu() { - onPreview(index) - } - - @objc private func openFromMenu() { - onOpen(index) - } - - @objc private func revealFromMenu() { - onReveal(index) - } - - @objc private func togglePinFromMenu() { - onTogglePin(index) - } - - @objc private func assignToCollectionFromMenu(_ sender: NSMenuItem) { - guard let name = sender.representedObject as? String else { return } - onAssignCollection(index, name) - } - - @objc private func createCollectionFromMenu() { + private func createCollection() { let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) input.placeholderString = "Collection name" - input.stringValue = "" let alert = NSAlert() alert.messageText = "New Collection" @@ -6277,31 +4661,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onAssignCollection(index, name) } - @objc private func removeFromCollectionFromMenu() { - onAssignCollection(index, nil) - } - @objc private func ignoreSourceAppFromMenu() { - onIgnoreSourceApp(index) - } - - @objc private func ignoreKindFromMenu() { - onIgnoreKind(index) - } - - @objc private func deleteFromMenu() { - onDelete(index) - } private func configure(item: ClipboardItem, thumbnail: NSImage?) { - #if DEBUG - debugPreviewSummary = "\(titleText(for: item))|\(previewText(for: item))|\(detailMetricText(for: item))" - debugPreviewStyle = previewStyle(for: item, thumbnail: thumbnail) - debugHeaderBadgeSymbol = headerBadgeSymbol(for: item.kind) - debugHeaderTitle = headerTitle(for: item) - debugHeaderSubtitle = headerSubtitle(for: item) - debugHeaderColorHex = Self.debugHex(headerColor(for: item)) - #endif wantsLayer = true layer?.cornerRadius = 8 @@ -6393,19 +4755,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { header.layer?.masksToBounds = true header.layer?.zPosition = 2 - let title = NSTextField(labelWithString: headerTitle(for: item)) - title.font = .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .semibold) - title.textColor = .white - title.lineBreakMode = .byTruncatingTail - title.maximumNumberOfLines = 1 - title.toolTip = title.stringValue - - let subtitle = NSTextField(labelWithString: headerSubtitle(for: item)) - subtitle.font = .systemFont(ofSize: layout.isCompact ? 10 : 11, weight: .regular) - subtitle.textColor = NSColor.white.withAlphaComponent(0.78) - subtitle.lineBreakMode = .byTruncatingTail - subtitle.maximumNumberOfLines = 1 - subtitle.toolTip = subtitle.stringValue + let title = makeLabel( + headerTitle(for: item), + font: .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .semibold), + color: .white + ) + let subtitle = makeLabel( + headerSubtitle(for: item), + font: .systemFont(ofSize: layout.isCompact ? 10 : 11), + color: NSColor.white.withAlphaComponent(0.78) + ) let titleAndSubtitle = NSStackView(views: [title, subtitle]) titleAndSubtitle.orientation = .vertical @@ -6486,12 +4845,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func compactMetricLabel(for item: ClipboardItem) -> NSTextField { - let label = NSTextField(labelWithString: compactMetricText(for: item)) - label.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 10 : 11, weight: .semibold) - label.textColor = .tertiaryLabelColor - label.alignment = .right - label.lineBreakMode = .byClipping - label.maximumNumberOfLines = 1 + let label = makeLabel( + compactMetricText(for: item), + font: .monospacedDigitSystemFont(ofSize: layout.isCompact ? 10 : 11, weight: .semibold), + color: .tertiaryLabelColor, + alignment: .right, + lineBreakMode: .byClipping + ) label.toolTip = detailMetricText(for: item) label.isHidden = true return label @@ -6602,13 +4962,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let titleString = titleText(for: item) let bodyString = previewBodyText(for: item, title: titleString) - #if DEBUG - debugTextPreviewTitle = titleString - debugTextPreviewBody = bodyString ?? "" - debugTextPreviewChrome = "paper-preview" - debugTextPreviewAccentHex = Self.debugHex(accent) - debugTextPreviewFadePlacement = bodyString == nil ? "none" : "bottom" - #endif let title = NSTextField(wrappingLabelWithString: titleString) title.font = bodyString == nil ? .systemFont(ofSize: item.kind == .richText ? 16 : 15, weight: .semibold) @@ -6690,13 +5043,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let siteColor = linkVisualColor(for: hostText) let monogramText = linkMonogram(from: hostText) let heroHeight = linkPreviewHeroHeight - #if DEBUG - debugLinkPreviewChrome = "browser-card" - debugLinkPreviewHostText = hostText - debugLinkPreviewMonogram = monogramText - debugLinkPreviewAccentHex = Self.debugHex(siteColor) - debugLinkPreviewHeroHeight = heroHeight - #endif let hero = NSView() hero.wantsLayer = true @@ -6732,12 +5078,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { addressPill.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.06).cgColor addressPill.translatesAutoresizingMaskIntoConstraints = false - let host = NSTextField(labelWithString: hostText) - host.font = .systemFont(ofSize: layout.isCompact ? 8 : 8.5, weight: .medium) - host.textColor = .secondaryLabelColor - host.maximumNumberOfLines = 1 - host.lineBreakMode = .byTruncatingMiddle - host.toolTip = host.stringValue + let host = makeLabel( + hostText, + font: .systemFont(ofSize: layout.isCompact ? 8 : 8.5, weight: .medium), + color: .secondaryLabelColor, + lineBreakMode: .byTruncatingMiddle + ) host.translatesAutoresizingMaskIntoConstraints = false addressPill.addSubview(host) @@ -6751,12 +5097,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { favicon.layer?.shadowOffset = NSSize(width: 0, height: 2) favicon.translatesAutoresizingMaskIntoConstraints = false - let monogram = NSTextField(labelWithString: monogramText) - monogram.font = .systemFont(ofSize: layout.isCompact ? 13 : 15, weight: .heavy) - monogram.textColor = .white - monogram.alignment = .center - monogram.lineBreakMode = .byClipping - monogram.maximumNumberOfLines = 1 + let monogram = makeLabel( + monogramText, + font: .systemFont(ofSize: layout.isCompact ? 13 : 15, weight: .heavy), + color: .white, + alignment: .center, + lineBreakMode: .byClipping + ) monogram.translatesAutoresizingMaskIntoConstraints = false favicon.addSubview(monogram) @@ -6779,19 +5126,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { browser.addSubview(lineStack) hero.addSubview(browser) - let title = NSTextField(wrappingLabelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 2 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let address = NSTextField(labelWithString: previewText(for: item)) - address.font = .systemFont(ofSize: 12) - address.textColor = .secondaryLabelColor - address.maximumNumberOfLines = 1 - address.lineBreakMode = .byTruncatingMiddle - address.toolTip = address.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: 14, weight: .semibold), + color: .labelColor, + lines: 2, + wraps: true + ) + let address = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor, + lineBreakMode: .byTruncatingMiddle + ) let textStack = NSStackView(views: [title, address]) textStack.orientation = .vertical @@ -6889,31 +5236,24 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { container.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.22).cgColor container.translatesAutoresizingMaskIntoConstraints = false - let hostText = webHostText(from: item.payload) ?? "Link" let imageHeight = linkMediaPreviewImageHeight - #if DEBUG - debugLinkMediaPreviewChrome = "thumbnail-card" - debugLinkMediaPreviewImageHeight = imageHeight - debugLinkMediaPreviewHostText = hostText - #endif let imageView = AspectFillImageView(image: thumbnail) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.heightAnchor.constraint(equalToConstant: imageHeight).isActive = true - let title = NSTextField(wrappingLabelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 1 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let address = NSTextField(labelWithString: previewText(for: item)) - address.font = .systemFont(ofSize: 12) - address.textColor = .secondaryLabelColor - address.maximumNumberOfLines = 1 - address.lineBreakMode = .byTruncatingMiddle - address.toolTip = address.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: 14, weight: .semibold), + color: .labelColor, + wraps: true + ) + let address = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor, + lineBreakMode: .byTruncatingMiddle + ) let textStack = NSStackView(views: [title, address]) textStack.orientation = .vertical @@ -6949,31 +5289,24 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let accent = accentColor(for: item.kind) let extensionText = detailMetricText(for: item) let coverSize = filePreviewCoverSize - #if DEBUG - debugFilePreviewChrome = "document-cover" - debugFilePreviewExtensionText = extensionText - debugFilePreviewLocationPlacement = "below-cover" - debugFilePreviewCoverSize = coverSize - #endif container.wantsLayer = true container.layer?.backgroundColor = accent.withAlphaComponent(0.045).cgColor container.translatesAutoresizingMaskIntoConstraints = false let cover = documentCoverPreviewView(for: item, extensionText: extensionText, accent: accent) - let title = NSTextField(wrappingLabelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: layout.isCompact ? 13 : 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 1 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let location = NSTextField(labelWithString: previewText(for: item)) - location.font = .systemFont(ofSize: layout.isCompact ? 11 : 12) - location.textColor = .secondaryLabelColor - location.maximumNumberOfLines = 1 - location.lineBreakMode = .byTruncatingMiddle - location.toolTip = location.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: layout.isCompact ? 13 : 14, weight: .semibold), + color: .labelColor, + wraps: true + ) + let location = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: layout.isCompact ? 11 : 12), + color: .secondaryLabelColor, + lineBreakMode: .byTruncatingMiddle + ) let textStack = NSStackView(views: [title, location]) textStack.orientation = .vertical @@ -7105,19 +5438,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { tileStack.spacing = layout.isCompact ? 6 : 8 tileStack.translatesAutoresizingMaskIntoConstraints = false - let title = NSTextField(labelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 1 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let location = NSTextField(labelWithString: previewText(for: item)) - location.font = .systemFont(ofSize: 12) - location.textColor = .secondaryLabelColor - location.maximumNumberOfLines = 1 - location.lineBreakMode = .byTruncatingMiddle - location.toolTip = location.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: 14, weight: .semibold), + color: .labelColor + ) + let location = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor, + lineBreakMode: .byTruncatingMiddle + ) let labels = NSStackView(views: [title, location]) labels.orientation = .vertical @@ -7199,11 +5530,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { container.translatesAutoresizingMaskIntoConstraints = false let artworkSize = audioArtworkSize - #if DEBUG - debugAudioPreviewChrome = "album-card" - debugAudioArtworkSize = artworkSize - debugAudioLabelPlacement = "below-artwork" - #endif let artwork = NSView() artwork.wantsLayer = true @@ -7237,19 +5563,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { artwork.addSubview(centerDot) artwork.addSubview(note) - let title = NSTextField(labelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 1 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let detail = NSTextField(labelWithString: previewText(for: item)) - detail.font = .systemFont(ofSize: 12) - detail.textColor = .secondaryLabelColor - detail.maximumNumberOfLines = 1 - detail.lineBreakMode = .byTruncatingTail - detail.toolTip = detail.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: 14, weight: .semibold), + color: .labelColor + ) + let detail = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor + ) let labels = NSStackView(views: [title, detail]) labels.orientation = .vertical @@ -7308,11 +5631,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { container.translatesAutoresizingMaskIntoConstraints = false let frameHeight = videoPreviewFrameHeight - #if DEBUG - debugVideoPreviewChrome = "player-card" - debugVideoFrameHeight = frameHeight - debugVideoFormatPlacement = "bottom-center" - #endif let frame = NSView() frame.wantsLayer = true @@ -7348,19 +5666,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { extensionPill.translatesAutoresizingMaskIntoConstraints = false frame.addSubview(extensionPill) - let title = NSTextField(labelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) - title.textColor = .labelColor - title.maximumNumberOfLines = 1 - title.lineBreakMode = .byTruncatingTail - title.toolTip = title.stringValue - - let detail = NSTextField(labelWithString: previewText(for: item)) - detail.font = .systemFont(ofSize: 12) - detail.textColor = .secondaryLabelColor - detail.maximumNumberOfLines = 1 - detail.lineBreakMode = .byTruncatingTail - detail.toolTip = detail.stringValue + let title = makeLabel( + titleText(for: item), + font: .systemFont(ofSize: 14, weight: .semibold), + color: .labelColor + ) + let detail = makeLabel( + previewText(for: item), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor + ) let labels = NSStackView(views: [title, detail]) labels.orientation = .vertical @@ -7411,12 +5726,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let hexText = ColorPayload.displayHex(from: item.payload) let componentText = ColorPayload.componentSummary(from: item.payload) let chipSize = colorPreviewChipSize - #if DEBUG - debugColorPreviewChrome = "paint-chip" - debugColorPreviewHexText = hexText - debugColorPreviewSwatchPlacement = "top" - debugColorPreviewChipSize = chipSize - #endif let container = NSView() container.wantsLayer = true @@ -7446,21 +5755,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { swatch.layer?.backgroundColor = swatchColor.cgColor swatch.translatesAutoresizingMaskIntoConstraints = false - let hex = NSTextField(labelWithString: hexText) - hex.font = .monospacedDigitSystemFont(ofSize: colorPreviewHexFontSize, weight: .bold) - hex.textColor = .labelColor - hex.alignment = .center - hex.maximumNumberOfLines = 1 - hex.lineBreakMode = .byTruncatingTail - hex.toolTip = hex.stringValue - - let components = NSTextField(labelWithString: componentText) - components.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 10 : 11, weight: .semibold) - components.textColor = .secondaryLabelColor - components.alignment = .center - components.maximumNumberOfLines = 1 - components.lineBreakMode = .byTruncatingTail - components.toolTip = components.stringValue + let hex = makeLabel( + hexText, + font: .monospacedDigitSystemFont(ofSize: colorPreviewHexFontSize, weight: .bold), + color: .labelColor, + alignment: .center + ) + let components = makeLabel( + componentText, + font: .monospacedDigitSystemFont(ofSize: layout.isCompact ? 10 : 11, weight: .semibold), + color: .secondaryLabelColor, + alignment: .center + ) let labelStack = NSStackView(views: [hex, components]) labelStack.orientation = .vertical @@ -7570,20 +5876,20 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func codeLineRow(number: Int, text: String) -> NSView { - let numberLabel = NSTextField(labelWithString: "\(number)") - numberLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) - numberLabel.textColor = .tertiaryLabelColor - numberLabel.alignment = .right - numberLabel.maximumNumberOfLines = 1 + let numberLabel = makeLabel( + "\(number)", + font: .monospacedDigitSystemFont(ofSize: 11, weight: .regular), + color: .tertiaryLabelColor, + alignment: .right + ) numberLabel.translatesAutoresizingMaskIntoConstraints = false numberLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true - let codeLabel = NSTextField(labelWithString: text) - codeLabel.font = .monospacedSystemFont(ofSize: layout.isCompact ? 11 : 12, weight: .regular) - codeLabel.textColor = .labelColor - codeLabel.maximumNumberOfLines = 1 - codeLabel.lineBreakMode = .byTruncatingTail - codeLabel.toolTip = text + let codeLabel = makeLabel( + text, + font: .monospacedSystemFont(ofSize: layout.isCompact ? 11 : 12, weight: .regular), + color: .labelColor + ) codeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let row = NSStackView(views: [numberLabel, codeLabel]) @@ -7603,10 +5909,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { imageView.translatesAutoresizingMaskIntoConstraints = false let metricText = mediaMetricText(for: thumbnail) - #if DEBUG - debugMediaMetricText = metricText - debugMediaMetricPlacement = "bottom-center" - #endif let overlay = capsuleLabel(metricText, color: NSColor.black.withAlphaComponent(0.56)) overlay.translatesAutoresizingMaskIntoConstraints = false @@ -7627,11 +5929,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false - #if DEBUG - debugVideoPreviewChrome = "thumbnail-player" - debugVideoFrameHeight = layout.bodyHeight - debugVideoFormatPlacement = "bottom-center" - #endif let imageView = AspectFillImageView(image: thumbnail) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -7716,40 +6013,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "\(width) x \(height)" } - private func previewStyle(for item: ClipboardItem, thumbnail: NSImage?) -> String { - if item.kind == .image, thumbnail != nil { - return "media-preview" - } - - switch item.kind { - case .url: - return thumbnail == nil ? "link-site-preview" : "link-media-preview" - case .file: - if thumbnail != nil { - return "file-media-preview" - } - return FilePayload.paths(from: item.payload).count > 1 ? "multi-file-preview" : "file-preview" - case .pdf: - return thumbnail == nil ? "file-preview" : "file-media-preview" - case .audio: - return "audio-preview" - case .video: - return thumbnail == nil ? "video-preview" : "video-media-preview" - case .color: - return "color-preview" - case .code: - return "code-preview" - case .richText: - return "rich-text-preview" - case .text: - return "text-preview" - case .image: - return "text-fallback-preview" - case .unknown: - return "unknown-preview" - } - } - private func footerView(for item: ClipboardItem) -> NSView { let footer = NSView() footer.wantsLayer = true @@ -7826,9 +6089,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { badge.translatesAutoresizingMaskIntoConstraints = false if let bundleId = item.sourceAppBundleId, let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { - #if DEBUG - debugHeaderBadgeText = "" - #endif let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path)) icon.imageScaling = .scaleProportionallyUpOrDown icon.translatesAutoresizingMaskIntoConstraints = false @@ -7853,9 +6113,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { icon.bottomAnchor.constraint(equalTo: iconClipView.bottomAnchor, constant: iconBleed) ]) } else if let monogram = Self.sourceMonogram(from: itemSourceAppName) { - #if DEBUG - debugHeaderBadgeText = monogram - #endif let label = NSTextField(labelWithString: monogram) label.font = .systemFont(ofSize: layout.isCompact ? 15 : 17, weight: .heavy) label.textColor = accentColor(for: item.kind) @@ -7872,9 +6129,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { label.centerYAnchor.constraint(equalTo: badge.centerYAnchor) ]) } else { - #if DEBUG - debugHeaderBadgeText = "" - #endif let image = NSImage(systemSymbolName: headerBadgeSymbol(for: item.kind), accessibilityDescription: kindLabel(for: item.kind)) ?? NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: kindLabel(for: item.kind)) ?? NSImage() @@ -7896,12 +6150,25 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return badge } - private func separatorLine() -> NSView { - let divider = NSView() - divider.wantsLayer = true - divider.translatesAutoresizingMaskIntoConstraints = false - divider.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.6).cgColor - return divider + private func makeLabel( + _ text: String, + font: NSFont, + color: NSColor, + lines: Int = 1, + alignment: NSTextAlignment = .natural, + lineBreakMode: NSLineBreakMode = .byTruncatingTail, + wraps: Bool = false + ) -> NSTextField { + let label = wraps + ? NSTextField(wrappingLabelWithString: text) + : NSTextField(labelWithString: text) + label.font = font + label.textColor = color + label.maximumNumberOfLines = lines + label.alignment = alignment + label.lineBreakMode = lineBreakMode + label.toolTip = text + return label } private func headerIcon(_ name: String, color: NSColor) -> NSView { diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 12e8ce6..10f1f97 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -136,6 +136,18 @@ final class ClipboardPanelViewModel { case original } + private enum TransferIntent { + case copy + case paste + } + + private enum TransferContext { + case single + case selected + case stackItem + case stackText + } + private(set) var visibleItems: [ClipboardItem] = [] { didSet { var nextItemByID: [UUID: ClipboardItem] = [:] @@ -324,18 +336,6 @@ final class ClipboardPanelViewModel { var onStackChanged: (() -> Void)? var onCaptureStatusChanged: (() -> Void)? - #if DEBUG - private(set) var debugVisibleItemsFullScanCount = 0 - private(set) var debugVisibleItemsIndexedLookupCount = 0 - private(set) var debugCollectionCountFullScanCount = 0 - private(set) var debugCollectionCountIndexedLookupCount = 0 - private(set) var debugSearchItemEvaluationCount = 0 - private(set) var debugSearchMatchCacheHitCount = 0 - private(set) var debugSearchDocumentBuildCount = 0 - private(set) var debugSearchDocumentCacheHitCount = 0 - private(set) var debugCategoryFilterSelectionBuildCount = 0 - private(set) var debugStackPruneScanCount = 0 - #endif init( store: ClipboardStore, @@ -392,20 +392,6 @@ final class ClipboardPanelViewModel { } } - #if DEBUG - func debugResetVisibleItemsPerformanceCounters() { - debugVisibleItemsFullScanCount = 0 - debugVisibleItemsIndexedLookupCount = 0 - debugCollectionCountFullScanCount = 0 - debugCollectionCountIndexedLookupCount = 0 - debugSearchItemEvaluationCount = 0 - debugSearchMatchCacheHitCount = 0 - debugSearchDocumentBuildCount = 0 - debugSearchDocumentCacheHitCount = 0 - debugCategoryFilterSelectionBuildCount = 0 - debugStackPruneScanCount = 0 - } - #endif var selectedItem: ClipboardItem? { guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil } @@ -445,10 +431,6 @@ final class ClipboardPanelViewModel { stackItemIDs.count } - var stackTitle: String { - "Stack" - } - var collectionNames: [String] { if let collectionNamesCache { return collectionNamesCache @@ -473,14 +455,6 @@ final class ClipboardPanelViewModel { return names } - var searchFilterSourceAppNames: [String] { - uniqueSearchFacetValues(items.compactMap(\.sourceApp)) - } - - var searchFilterDeviceNames: [String] { - uniqueSearchFacetValues(items.map { Optional(effectiveSourceDeviceName(for: $0)) }) - } - func collectionCount(for sortMode: ClipboardSortMode) -> Int { collectionCountSummary().count(for: sortMode) } @@ -505,9 +479,6 @@ final class ClipboardPanelViewModel { for (collectionKey, indexedItems) in indexedItemsByCollectionKey { collectionCounts[collectionKey] = indexedItems.count } - #if DEBUG - debugCollectionCountIndexedLookupCount += 1 - #endif } else { for indexedItem in indexedItemsMatchingSearch(query) { let item = indexedItem.item @@ -516,9 +487,6 @@ final class ClipboardPanelViewModel { collectionCounts[collectionName.lowercased(), default: 0] += 1 } } - #if DEBUG - debugCollectionCountFullScanCount += 1 - #endif } let summary = ClipboardCollectionCountSummary( @@ -673,83 +641,35 @@ final class ClipboardPanelViewModel { } func pasteSelected() { - if selectedItemCount > 1 { - pasteSelectedItems() - return - } - guard let item = selectedItem else { return } - let result = pasteService.paste(item, targetApp: targetApplicationProvider()) - if case .pasted = result { - willPasteToTarget() - } - if case .failed = result {} else { - store.markUsed(item.id) - selectedItemID = item.id - } - statusMessage = result.message - settings.setPasteStatus(message: result.message) + performSelectedTransfer(.paste) } func pasteSelectedPlainText() { - if selectedItemCount > 1 { - pasteSelectedItemsAsText() - return - } - guard let item = selectedItem else { return } - let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider()) - if case .pastedPlainText = result { - willPasteToTarget() - } - if case .failed = result {} else { - store.markUsed(item.id) - selectedItemID = item.id - } - statusMessage = result.message - settings.setPasteStatus(message: result.message) + performSelectedTransfer(.paste, asPlainText: true) } func pasteItem(at index: Int) { - guard index >= 0 && index < visibleItems.count else { return } + guard visibleItems.indices.contains(index) else { return } selectItem(at: index) pasteSelected() } func pasteItemPlainText(at index: Int) { - guard index >= 0 && index < visibleItems.count else { return } + guard visibleItems.indices.contains(index) else { return } selectItem(at: index) pasteSelectedPlainText() } func copySelected() { - if selectedItemCount > 1 { - copySelectedItems() - return - } - guard let item = selectedItem else { return } - let result = pasteService.copy(item) - if case .failed = result {} else { - store.markUsed(item.id) - selectedItemID = item.id - } - statusMessage = result.message - settings.setPasteStatus(message: result.message) + performSelectedTransfer(.copy) } func copySelectedPlainText() { - if selectedItemCount > 1 { - copySelectedItemsAsText() - return - } - guard let item = selectedItem else { return } - let result = pasteService.copyPlainText(item) - if case .failed = result {} else { - store.markUsed(item.id) - selectedItemID = item.id - } - statusMessage = result.message - settings.setPasteStatus(message: result.message) + performSelectedTransfer(.copy, asPlainText: true) } + + func isItemStacked(at index: Int) -> Bool { guard index >= 0 && index < visibleItems.count else { return false } return stackItemIDSet.contains(visibleItems[index].id) @@ -830,11 +750,6 @@ final class ClipboardPanelViewModel { } } - func clearStackSelection() { - guard isStackFilterSelected else { return } - isStackFilterSelected = false - } - func clearStack() { guard !stackItemIDs.isEmpty else { statusMessage = "Stack is empty" @@ -846,51 +761,23 @@ final class ClipboardPanelViewModel { } func copyNextStackItem() { - guard let item = nextStackItem() else { - statusMessage = "Stack is empty" - return - } - - let result = pasteService.copy(item) - handleStackActionResult(result, item: item) + performNextStackTransfer(.copy) } func pasteNextStackItem() { - guard let item = nextStackItem() else { - statusMessage = "Stack is empty" - return - } - - let result = pasteService.paste(item, targetApp: targetApplicationProvider()) - if case .pasted = result { - willPasteToTarget() - } - handleStackActionResult(result, item: item) + performNextStackTransfer(.paste) } func copyStackAsText() { - guard let package = stackPlainTextPackage() else { - statusMessage = "Stack has no text to copy" - return - } - - let result = pasteService.copyPlainText(package.text) - handleStackPlainTextActionResult(result, items: package.items) + performStackTextTransfer(.copy) } func pasteStackAsText() { - guard let package = stackPlainTextPackage() else { - statusMessage = "Stack has no text to paste" - return - } - - let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider()) - if case .pastedPlainText = result { - willPasteToTarget() - } - handleStackPlainTextActionResult(result, items: package.items) + performStackTextTransfer(.paste) } + + func addSelectedItemsToStack() { pruneStackItems() let selectedItems = selectedItemsInSelectionOrder() @@ -912,54 +799,16 @@ final class ClipboardPanelViewModel { statusMessage = "Added \(newIDs.count) selected \(noun) to Stack" } - func copySelectedItems() { - let selectedItems = selectedItemsInSelectionOrder() - guard selectedItems.count > 1 else { - copySelected() - return - } - - let result = pasteService.copy(selectedItems) - handleSelectedActionResult(result, items: selectedItems) - } - - func pasteSelectedItems() { - let selectedItems = selectedItemsInSelectionOrder() - guard selectedItems.count > 1 else { - pasteSelected() - return - } - - let result = pasteService.paste(selectedItems, targetApp: targetApplicationProvider()) - if case .pasted = result { - willPasteToTarget() - } - handleSelectedActionResult(result, items: selectedItems) - } - func copySelectedItemsAsText() { - guard let package = selectedPlainTextPackage() else { - statusMessage = "Selection has no text to copy" - return - } - - let result = pasteService.copyPlainText(package.text) - handleSelectedPlainTextActionResult(result, items: package.items) + performSelectedTransfer(.copy, asPlainText: true, forceGroup: true) } func pasteSelectedItemsAsText() { - guard let package = selectedPlainTextPackage() else { - statusMessage = "Selection has no text to paste" - return - } - - let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider()) - if case .pastedPlainText = result { - willPasteToTarget() - } - handleSelectedPlainTextActionResult(result, items: package.items) + performSelectedTransfer(.paste, asPlainText: true, forceGroup: true) } + + func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { guard index >= 0 && index < visibleItems.count else { return [] } return pasteService.pasteboardWriters(for: visibleItems[index]) @@ -970,13 +819,6 @@ final class ClipboardPanelViewModel { return item.payload } - func editableTextForItem(at index: Int) -> String? { - guard index >= 0 && index < visibleItems.count else { return nil } - let item = visibleItems[index] - guard item.kind == .text || item.kind == .code else { return nil } - return item.payload - } - func editableTitleForSelected() -> String? { guard let item = selectedItem else { return nil } return item.customTitle ?? "" @@ -1540,10 +1382,6 @@ final class ClipboardPanelViewModel { statusMessage = "Deleted \(normalizedName)" } - func clearSearch() { - searchText = "" - } - func showSelectedInClipboard() { guard canShowSelectedInClipboard, let item = selectedItem else { return } selectedItemID = item.id @@ -1762,156 +1600,198 @@ final class ClipboardPanelViewModel { } } - private func uniqueSearchFacetValues(_ values: [String?]) -> [String] { - var seen = Set() - var result: [String] = [] - for value in values { - guard let normalized = value?.clipboardTrimmed, !normalized.isEmpty else { continue } - let key = Self.normalizedSearchValue(normalized) - guard seen.insert(key).inserted else { continue } - result.append(normalized) - } - return result.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - } - private func selectedItemsInSelectionOrder() -> [ClipboardItem] { - let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] } - if !selectedItems.isEmpty { - return selectedItems - } - return selectedItem.map { [$0] } ?? [] + let items = selectedItemIDs.compactMap { visibleItemByID[$0] } + return items.isEmpty ? selectedItem.map { [$0] } ?? [] : items } private func nextStackItem() -> ClipboardItem? { pruneStackItems() - guard let id = stackItemIDs.first else { return nil } - return itemByID[id] + return stackItemIDs.first.flatMap { itemByID[$0] } } - private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? { - pruneStackItems() - let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in - guard let item = itemByID[id], - let text = pasteService.plainText(for: item)?.clipboardTrimmed, + private func plainTextPackage(for items: [ClipboardItem]) -> (text: String, items: [ClipboardItem])? { + let pairs = items.compactMap { item -> (ClipboardItem, String)? in + guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else { return nil } return (item, text) } guard !pairs.isEmpty else { return nil } - return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item)) + return (pairs.map(\.1).joined(separator: "\n\n"), pairs.map(\.0)) } - private func selectedPlainTextPackage() -> (text: String, items: [ClipboardItem])? { - let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in - guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else { - return nil + private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? { + pruneStackItems() + return plainTextPackage(for: stackItemIDs.compactMap { itemByID[$0] }) + } + + private func performSelectedTransfer( + _ intent: TransferIntent, + asPlainText: Bool = false, + forceGroup: Bool = false + ) { + let items = selectedItemsInSelectionOrder() + let isGroup = forceGroup || items.count > 1 + + if asPlainText && isGroup { + guard let package = plainTextPackage(for: items) else { + statusMessage = "Selection has no text to \(intent == .copy ? "copy" : "paste")" + return } - return (item, text) - } - guard !pairs.isEmpty else { return nil } - return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item)) - } - - private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) { - if case .failed(let message) = result { - statusMessage = message + completeTransfer( + plainTextTransfer(intent, value: package.text), + items: package.items, + context: .selected + ) return } - consumeStackItem(item.id) - store.markUsed(item.id) - selectedItemID = item.id - switch result { - case .copiedNeedsPermission: - statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically." - case .pasted: - statusMessage = "Pasted from Stack" - case .copied: - statusMessage = "Copied from Stack" - default: - statusMessage = result.message - } - settings.setPasteStatus(message: statusMessage) + guard let item = items.first else { return } + let result = asPlainText + ? plainTextTransfer(intent, item: item) + : originalTransfer(intent, items: items) + completeTransfer(result, items: items, context: isGroup ? .selected : .single) } - private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) { + private func performNextStackTransfer(_ intent: TransferIntent) { + guard let item = nextStackItem() else { + statusMessage = "Stack is empty" + return + } + completeTransfer(originalTransfer(intent, items: [item]), items: [item], context: .stackItem) + } + + private func performStackTextTransfer(_ intent: TransferIntent) { + guard let package = stackPlainTextPackage() else { + statusMessage = "Stack has no text to \(intent == .copy ? "copy" : "paste")" + return + } + completeTransfer( + plainTextTransfer(intent, value: package.text), + items: package.items, + context: .stackText + ) + } + + private func originalTransfer( + _ intent: TransferIntent, + items: [ClipboardItem] + ) -> PasteActionService.PasteActionResult { + if let item = items.first, items.count == 1 { + return intent == .copy + ? pasteService.copy(item) + : pasteService.paste(item, targetApp: targetApplicationProvider()) + } + return intent == .copy + ? pasteService.copy(items) + : pasteService.paste(items, targetApp: targetApplicationProvider()) + } + + private func plainTextTransfer( + _ intent: TransferIntent, + item: ClipboardItem + ) -> PasteActionService.PasteActionResult { + intent == .copy + ? pasteService.copyPlainText(item) + : pasteService.pastePlainText(item, targetApp: targetApplicationProvider()) + } + + private func plainTextTransfer( + _ intent: TransferIntent, + value: String + ) -> PasteActionService.PasteActionResult { + intent == .copy + ? pasteService.copyPlainText(value) + : pasteService.pastePlainText(value, targetApp: targetApplicationProvider()) + } + + private func completeTransfer( + _ result: PasteActionService.PasteActionResult, + items: [ClipboardItem], + context: TransferContext + ) { if case .failed(let message) = result { statusMessage = message + if case .single = context { + settings.setPasteStatus(message: message) + } return } - for item in items { - store.markUsed(item.id) - consumeStackItem(item.id, refreshActiveStackFilter: false) + if case .pasted = result { + willPasteToTarget() + } else if case .pastedPlainText = result { + willPasteToTarget() } - if stackItemIDs.isEmpty { - isStackFilterSelected = false - } else if isStackFilterSelected { - recomputeVisibleItems() + + switch context { + case .stackItem: + if let item = items.first { + consumeStackItem(item.id) + store.markUsed(item.id) + } + case .stackText: + for item in items { + store.markUsed(item.id) + consumeStackItem(item.id, refreshActiveStackFilter: false) + } + if stackItemIDs.isEmpty { + isStackFilterSelected = false + } else if isStackFilterSelected { + recomputeVisibleItems() + } + case .single, .selected: + for item in items { + store.markUsed(item.id) + } } + selectedItemID = items.first?.id - let noun = items.count == 1 ? "clip" : "clips" - switch result { - case .pastedPlainText: - statusMessage = "Pasted \(items.count) Stack \(noun) as Text" - case .copiedPlainTextNeedsPermission: - statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically." - case .copiedPlainText: - statusMessage = "Copied \(items.count) Stack \(noun) as Text" - default: - statusMessage = result.message - } + statusMessage = transferStatus(result, count: items.count, context: context) settings.setPasteStatus(message: statusMessage) } - private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) { - if case .failed(let message) = result { - statusMessage = message - return - } - - for item in items { - store.markUsed(item.id) - } - selectedItemID = items.first?.id - let noun = items.count == 1 ? "clip" : "clips" - switch result { - case .pasted: - statusMessage = "Pasted \(items.count) selected \(noun)" - case .copiedNeedsPermission: - statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically." - case .copied: - statusMessage = "Copied \(items.count) selected \(noun)" + private func transferStatus( + _ result: PasteActionService.PasteActionResult, + count: Int, + context: TransferContext + ) -> String { + let noun = count == 1 ? "clip" : "clips" + switch (context, result) { + case (.single, _): + return result.message + case (.stackItem, .pasted): + return "Pasted from Stack" + case (.stackItem, .copied): + return "Copied from Stack" + case (.stackItem, .copiedNeedsPermission): + return "Copied from Stack. Grant Accessibility access to paste automatically." + case (.selected, .pasted): + return "Pasted \(count) selected \(noun)" + case (.selected, .copied): + return "Copied \(count) selected \(noun)" + case (.selected, .copiedNeedsPermission): + return "Copied \(count) selected \(noun). Grant Accessibility access to paste automatically." + case (.selected, .pastedPlainText): + return "Pasted \(count) selected \(noun) as Text" + case (.selected, .copiedPlainText): + return "Copied \(count) selected \(noun) as Text" + case (.selected, .copiedPlainTextNeedsPermission): + return "Copied \(count) selected \(noun) as Text. Grant Accessibility access to paste automatically." + case (.stackText, .pastedPlainText): + return "Pasted \(count) Stack \(noun) as Text" + case (.stackText, .copiedPlainText): + return "Copied \(count) Stack \(noun) as Text" + case (.stackText, .copiedPlainTextNeedsPermission): + return "Copied \(count) Stack \(noun) as Text. Grant Accessibility access to paste automatically." default: - statusMessage = result.message + return result.message } - settings.setPasteStatus(message: statusMessage) } - private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) { - if case .failed(let message) = result { - statusMessage = message - return - } - for item in items { - store.markUsed(item.id) - } - selectedItemID = items.first?.id - let noun = items.count == 1 ? "clip" : "clips" - switch result { - case .pastedPlainText: - statusMessage = "Pasted \(items.count) selected \(noun) as Text" - case .copiedPlainTextNeedsPermission: - statusMessage = "Copied \(items.count) selected \(noun) as Text. Grant Accessibility access to paste automatically." - case .copiedPlainText: - statusMessage = "Copied \(items.count) selected \(noun) as Text" - default: - statusMessage = result.message - } - settings.setPasteStatus(message: statusMessage) - } private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) { guard let index = stackItemIDs.firstIndex(of: id) else { return } @@ -1925,9 +1805,6 @@ final class ClipboardPanelViewModel { guard stackItemsNeedPruning else { return } stackItemsNeedPruning = false guard !stackItemIDs.isEmpty else { return } - #if DEBUG - debugStackPruneScanCount += 1 - #endif let pruned = stackItemIDs.filter { itemIDSet.contains($0) } if pruned != stackItemIDs { stackItemIDs = pruned @@ -1960,9 +1837,6 @@ final class ClipboardPanelViewModel { private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] { if let searchMatchCache, searchMatchCache.query == query { - #if DEBUG - debugSearchMatchCacheHitCount += 1 - #endif return searchMatchCache.indexedItems } @@ -1975,9 +1849,6 @@ final class ClipboardPanelViewModel { var matches: [IndexedClipboardItem] = [] matches.reserveCapacity(allIndexedItems.count) for indexedItem in allIndexedItems { - #if DEBUG - debugSearchItemEvaluationCount += 1 - #endif if matchesSearchQuery(indexedItem.item, query: parsedQuery) { matches.append(indexedItem) } @@ -2019,9 +1890,6 @@ final class ClipboardPanelViewModel { ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters) ) computed = indexedItems.map(\.item) - #if DEBUG - debugVisibleItemsIndexedLookupCount += 1 - #endif } else { computed = filterAndSortVisibleItems( indexedItemsMatchingSearch(query), @@ -2029,9 +1897,6 @@ final class ClipboardPanelViewModel { collectionName: collectionName, categoryFilters: categoryFilters ) - #if DEBUG - debugVisibleItemsFullScanCount += 1 - #endif } if visibleItemsCache.count > 24 { visibleItemsCache.removeAll(keepingCapacity: true) @@ -2201,9 +2066,6 @@ final class ClipboardPanelViewModel { ) } categoryFilterSelectionCache = selection - #if DEBUG - debugCategoryFilterSelectionBuildCount += 1 - #endif return selection } @@ -2344,9 +2206,6 @@ final class ClipboardPanelViewModel { ocrText: item.ocrText ) if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint { - #if DEBUG - debugSearchDocumentCacheHitCount += 1 - #endif return cached } @@ -2368,9 +2227,6 @@ final class ClipboardPanelViewModel { collection: item.collectionName.map(normalizedStructuredValue) ?? "" ) searchDocumentsByItemID[item.id] = document - #if DEBUG - debugSearchDocumentBuildCount += 1 - #endif return document } diff --git a/sources/clipbored/views/LinkPreviewWindowController.swift b/sources/clipbored/views/LinkPreviewWindowController.swift index f9870e5..c235d8f 100644 --- a/sources/clipbored/views/LinkPreviewWindowController.swift +++ b/sources/clipbored/views/LinkPreviewWindowController.swift @@ -352,53 +352,4 @@ final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegat decisionHandler(.allow) } - #if DEBUG - var debugTitleText: String { - titleLabel.stringValue - } - - var debugAddressText: String { - addressLabel.stringValue - } - - var debugStatusText: String { - statusLabel.stringValue - } - - var debugTitleTooltip: String? { - titleLabel.toolTip - } - - var debugAddressTooltip: String? { - addressLabel.toolTip - } - - var debugStatusTooltip: String? { - statusLabel.toolTip - } - - func debugPrepareForPreview(_ request: LinkPreviewRequest) { - prepareForPreview(request) - } - - func debugSetDisplayedPageURL(_ url: URL) { - setAddress(url) - } - - func debugOpenInBrowser() { - openInBrowser() - } - - func debugAllowObservedPageTitles() { - acceptsObservedPageTitles = true - } - - func debugApplyObservedPageTitle(_ title: String?) { - applyObservedPageTitle(title) - } - - func debugApplyNavigationFailure(_ error: Error) { - handleNavigationFailure(error) - } - #endif } diff --git a/sources/clipbored/views/OnboardingWindowController.swift b/sources/clipbored/views/OnboardingWindowController.swift index a3052f4..3b3221d 100644 --- a/sources/clipbored/views/OnboardingWindowController.swift +++ b/sources/clipbored/views/OnboardingWindowController.swift @@ -355,13 +355,4 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate { settings.iCloudSyncEnabled = iCloudSyncButton.state == .on } - #if DEBUG - var debugShowMenuBarIconIsEnabled: Bool { - showMenuBarIconButton.state == .on - } - - var debugShowDockIconIsEnabled: Bool { - showDockIconButton.state == .on - } - #endif } diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index d3a9e0d..d1bbdcf 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -13,6 +13,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel static let settingsContentMinimumWidth: CGFloat = 440 static let settingsLabelWidth: CGFloat = 128 } + private enum ControlCommand: Int { + case historyLength, historyRetention, pruneDuplicates, keepFirstImage, defaultSort + case launchAtLogin, showMenuBarIcon, showDockIcon, panelSide + case pauseCapture, excludeSensitive, includeImageText + case clearHistoryOnQuit, hideFromScreenCapture, requestAccessibility, refreshAccessibility + case pollProfile, cacheLimit + case iCloudSync, pushICloudArchive, pullICloudArchive, revealICloudFile + case openHistoryFolder, exportArchive, importArchive, clearHistory, clearCache + } private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"] private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled." private static let allowedContentTypesUpdatedMessage = "Allowed content types updated." @@ -82,11 +91,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let exportArchiveButton = NSButton() private let importArchiveButton = NSButton() - #if DEBUG - private var debugFullRefreshCountValue = 0 - private var debugIgnoredAppsRefreshCountValue = 0 - private var debugDestructiveActionConfirmationOverride: Bool? - #endif init( settings: SettingsModel, @@ -180,10 +184,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel return item } - private func tabTitle(for item: NSTabViewItem) -> String { - item.label.clipboardTrimmed - } - private func scrollContainer(for content: NSView) -> NSView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true @@ -216,26 +216,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel historyStepper.minValue = Double(AppConfiguration.minHistoryLength) historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength) historyStepper.increment = 25 - historyStepper.target = self - historyStepper.action = #selector(historyLengthChanged) + bind(historyStepper, to: .historyLength) historyStepper.setAccessibilityLabel("History length") - configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged)) + configurePopup(historyRetentionPopup, command: .historyRetention) historyRetentionPopup.setAccessibilityLabel("Keep history") for retention in HistoryRetention.allCases { addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup) } - configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged)) - configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged)) - configurePopup(defaultSortPopup, action: #selector(defaultSortChanged)) + configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", command: .pruneDuplicates) + configureCheckbox(keepFirstImageButton, title: "Keep first image copy", command: .keepFirstImage) + configurePopup(defaultSortPopup, command: .defaultSort) defaultSortPopup.setAccessibilityLabel("Default sort") for mode in ClipboardSortMode.allCases { addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup) } - configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged)) - configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged)) - configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged)) - configurePopup(panelSidePopup, action: #selector(panelSideChanged)) + configureCheckbox(launchAtLoginButton, title: "Launch at login", command: .launchAtLogin) + configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", command: .showMenuBarIcon) + configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", command: .showDockIcon) + configurePopup(panelSidePopup, command: .panelSide) panelSidePopup.setAccessibilityLabel("Shelf side") for side in ClipboardPanelSide.allCases { addPopupItem(side.title, side.rawValue, to: panelSidePopup) @@ -278,9 +277,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func captureSettingsView() -> NSView { - configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged)) - configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged)) - configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged)) + configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", command: .pauseCapture) + configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", command: .excludeSensitive) + configureCheckbox(includeImageTextButton, title: "Search in image labels", command: .includeImageText) configureStatusLabel(captureStatusLabel) let allowedRows = [ @@ -333,11 +332,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel let storageLabel = caption("History stays in Application Support. Text and managed media are encrypted with Keychain or an owner-only fallback key.") let screenPrivacyLabel = caption("When enabled, the clipboard panel is hidden from screenshots, screen sharing, and screen recordings.") let permissionHelpLabel = caption("Clipboard capture works without this permission. Grant Accessibility only for direct paste.") - configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged)) - configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", action: #selector(hideFromScreenCaptureChanged)) + configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", command: .clearHistoryOnQuit) + configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", command: .hideFromScreenCapture) configureStatusLabel(accessibilityStatusLabel) - let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess)) - let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus)) + let requestButton = button("Open Accessibility Settings", .requestAccessibility) + let refreshButton = button("Refresh Permission Status", .refreshAccessibility) configureStatusLabel(pasteStatusLabel) return page([ @@ -361,7 +360,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func performanceSettingsView() -> NSView { - configurePopup(pollProfilePopup, action: #selector(pollProfileChanged)) + configurePopup(pollProfilePopup, command: .pollProfile) pollProfilePopup.setAccessibilityLabel("Polling profile") for profile in AppConfiguration.PollProfile.allCases { addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup) @@ -370,8 +369,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024 cacheSlider.numberOfTickMarks = 9 cacheSlider.allowsTickMarkValuesOnly = true - cacheSlider.target = self - cacheSlider.action = #selector(cacheLimitChanged) + bind(cacheSlider, to: .cacheLimit) cacheSlider.setAccessibilityLabel("Image cache cap in megabytes") configureStatusLabel(cacheLabel) @@ -389,12 +387,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private func dataSettingsView() -> NSView { configureStatusLabel(dataStatusLabel) configureStatusLabel(cloudSyncStatusLabel) - configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", action: #selector(iCloudSyncChanged)) - configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive)) - configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive)) - configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile)) - configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive)) - configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive)) + configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", command: .iCloudSync) + configureButton(iCloudSyncNowButton, title: "Sync Now", command: .pushICloudArchive) + configureButton(iCloudRestoreButton, title: "Restore from iCloud", command: .pullICloudArchive) + configureButton(iCloudRevealButton, title: "Reveal Sync File", command: .revealICloudFile) + configureButton(exportArchiveButton, title: "Export Archive...", command: .exportArchive) + configureButton(importArchiveButton, title: "Import Archive...", command: .importArchive) let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.") let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.") return page([ @@ -416,9 +414,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ]) ]), section("Data", [ - button("Open History Folder", #selector(openHistoryFolder)), - button("Clear Clipboard History", #selector(clearClipboardHistory)), - button("Clear Thumbnail Cache", #selector(clearThumbnailCache)), + button("Open History Folder", .openHistoryFolder), + button("Clear Clipboard History", .clearHistory), + button("Clear Thumbnail Cache", .clearCache), dataStatusLabel ]) ]) @@ -472,20 +470,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel return label } - private func button(_ title: String, _ action: Selector) -> NSButton { + private func button(_ title: String, _ command: ControlCommand) -> NSButton { let control = NSButton() - configureButton(control, title: title, action: action) + configureButton(control, title: title, command: command) return control } - private func configureButton(_ control: NSButton, title: String, action: Selector) { + private func configureButton(_ control: NSButton, title: String, command: ControlCommand) { control.title = title - control.target = self - control.action = action + bind(control, to: command) control.bezelStyle = .rounded control.setAccessibilityLabel(title) } + private func configureCheckbox(_ control: NSButton, title: String, command: ControlCommand) { + configureCheckbox(control, title: title, action: #selector(performControlCommand(_:))) + control.tag = command.rawValue + } + private func configureCheckbox(_ control: NSButton, title: String, action: Selector) { control.setButtonType(.switch) control.title = title @@ -504,10 +506,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } - private func configurePopup(_ popup: NSPopUpButton, action: Selector) { + private func configurePopup(_ popup: NSPopUpButton, command: ControlCommand) { popup.removeAllItems() - popup.target = self - popup.action = action + bind(popup, to: command) + } + + private func bind(_ control: NSControl, to command: ControlCommand) { + control.tag = command.rawValue + control.target = self + control.action = #selector(performControlCommand(_:)) } private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) { @@ -628,9 +635,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) { - #if DEBUG - debugFullRefreshCountValue += 1 - #endif refreshHistoryLimitControls() refreshHistoryRetentionControl() @@ -969,9 +973,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func refreshIgnoredAppsTextView(force: Bool = false) { - #if DEBUG - debugIgnoredAppsRefreshCountValue += 1 - #endif guard force || !isEditingIgnoredApps else { return } @@ -1048,67 +1049,98 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func historyLengthChanged() { - settings.maxHistoryItems = historyStepper.integerValue - refreshHistoryLimitControls() - } - - @objc private func historyRetentionChanged() { - if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int, - let retention = HistoryRetention(rawValue: rawValue) { - settings.historyRetention = retention - } - } - - @objc private func pruneDuplicatesChanged() { - settings.pruneDuplicates = pruneDuplicatesButton.state == .on - } - - @objc private func keepFirstImageChanged() { - settings.keepFirstImage = keepFirstImageButton.state == .on - } - - @objc private func defaultSortChanged() { - if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int, - let mode = ClipboardSortMode(rawValue: rawValue) { - settings.defaultSortMode = mode - } - } - - @objc private func launchAtLoginChanged() { - let enabled = launchAtLoginButton.state == .on - settings.launchAtLogin = enabled - if !enabled { - settings.setLaunchAtLoginStatus(message: "") - } - refreshLaunchAtLoginControls() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.refreshLaunchAtLoginControls() - } - } - - @objc private func showMenuBarIconChanged() { - let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on - settings.showMenuBarIcon = shouldShowMenuBarIcon - if !shouldShowMenuBarIcon && !settings.showDockIcon { - settings.showDockIcon = true - } - refreshVisibilityControls() - } - - @objc private func showDockIconChanged() { - let shouldShowDockIcon = showDockIconButton.state == .on - settings.showDockIcon = shouldShowDockIcon - if !shouldShowDockIcon && !settings.showMenuBarIcon { - settings.showMenuBarIcon = true - } - refreshVisibilityControls() - } - - @objc private func panelSideChanged() { - if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int, - let side = ClipboardPanelSide(rawValue: rawValue) { - settings.panelSide = side + @objc private func performControlCommand(_ sender: NSControl) { + guard let command = ControlCommand(rawValue: sender.tag) else { return } + switch command { + case .historyLength: + settings.maxHistoryItems = historyStepper.integerValue + refreshHistoryLimitControls() + case .historyRetention: + if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int, + let retention = HistoryRetention(rawValue: rawValue) { + settings.historyRetention = retention + } + case .pruneDuplicates: + settings.pruneDuplicates = pruneDuplicatesButton.state == .on + case .keepFirstImage: + settings.keepFirstImage = keepFirstImageButton.state == .on + case .defaultSort: + if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int, + let mode = ClipboardSortMode(rawValue: rawValue) { + settings.defaultSortMode = mode + } + case .launchAtLogin: + let enabled = launchAtLoginButton.state == .on + settings.launchAtLogin = enabled + if !enabled { + settings.setLaunchAtLoginStatus(message: "") + } + refreshLaunchAtLoginControls() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.refreshLaunchAtLoginControls() + } + case .showMenuBarIcon: + let shouldShow = showMenuBarIconButton.state == .on + settings.showMenuBarIcon = shouldShow + if !shouldShow && !settings.showDockIcon { + settings.showDockIcon = true + } + refreshVisibilityControls() + case .showDockIcon: + let shouldShow = showDockIconButton.state == .on + settings.showDockIcon = shouldShow + if !shouldShow && !settings.showMenuBarIcon { + settings.showMenuBarIcon = true + } + refreshVisibilityControls() + case .panelSide: + if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int, + let side = ClipboardPanelSide(rawValue: rawValue) { + settings.panelSide = side + } + case .pauseCapture: + if pauseCaptureButton.state == .on { + settings.pauseCaptureUntil = nil + settings.pauseCapture = true + } else { + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + } + case .excludeSensitive: + settings.excludeSensitive = excludeSensitiveButton.state == .on + case .includeImageText: + settings.includeImageTextInSearch = includeImageTextButton.state == .on + case .clearHistoryOnQuit: + settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on + case .hideFromScreenCapture: + settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on + case .requestAccessibility: requestAccessibilityAccess() + case .refreshAccessibility: refreshAccessibilityPermissionStatus() + case .pollProfile: + if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int, + let profile = AppConfiguration.PollProfile(rawValue: rawValue) { + settings.pollProfileRaw = profile + } + case .cacheLimit: + let megabytes = Int(cacheSlider.doubleValue.rounded()) + settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024) + refreshCacheControls() + case .iCloudSync: + let enabled = iCloudSyncButton.state == .on + settings.iCloudSyncEnabled = enabled + settings.setCloudSyncStatus(message: "") + if !enabled { + cachedCloudSyncStatus = nil + } + refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil) + case .openHistoryFolder: openHistoryFolder() + case .exportArchive: exportClipboardArchive() + case .importArchive: importClipboardArchive() + case .pushICloudArchive: pushICloudSyncArchive() + case .pullICloudArchive: pullICloudSyncArchive() + case .revealICloudFile: revealICloudSyncFile() + case .clearHistory: clearClipboardHistory() + case .clearCache: clearThumbnailCache() } } @@ -1171,24 +1203,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func pauseCaptureChanged() { - if pauseCaptureButton.state == .on { - settings.pauseCaptureUntil = nil - settings.pauseCapture = true - } else { - settings.pauseCapture = false - settings.pauseCaptureUntil = nil - } - } - - @objc private func excludeSensitiveChanged() { - settings.excludeSensitive = excludeSensitiveButton.state == .on - } - - @objc private func includeImageTextChanged() { - settings.includeImageTextInSearch = includeImageTextButton.state == .on - } - @objc private func allowedKindChanged(_ sender: NSButton) { guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return } var ignored = settings.ignoredItemKindsRaw @@ -1228,15 +1242,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel commitIgnoredAppsDraftIfNeeded() } - @objc private func clearHistoryOnQuitChanged() { - settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on - } - - @objc private func hideFromScreenCaptureChanged() { - settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on - } - - @objc private func requestAccessibilityAccess() { + private func requestAccessibilityAccess() { _ = AccessibilityPermissionService.requestPromptIfNeeded() if !AccessibilityPermissionService.isTrusted { AccessibilityPermissionService.openSystemSettings() @@ -1247,7 +1253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel refreshAccessibilityPermissionStatus() } - @objc private func refreshAccessibilityPermissionStatus() { + private func refreshAccessibilityPermissionStatus() { settings.setAccessibilityPermissionStatus( message: AccessibilityPermissionService.isTrusted ? "" @@ -1256,34 +1262,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel refreshAccessibilityPermissionStatusLabel() } - @objc private func pollProfileChanged() { - if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int, - let profile = AppConfiguration.PollProfile(rawValue: rawValue) { - settings.pollProfileRaw = profile - } - } - - @objc private func cacheLimitChanged() { - let megabytes = Int(cacheSlider.doubleValue.rounded()) - settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024) - refreshCacheControls() - } - - @objc private func iCloudSyncChanged() { - let enabled = iCloudSyncButton.state == .on - settings.iCloudSyncEnabled = enabled - settings.setCloudSyncStatus(message: "") - if !enabled { - cachedCloudSyncStatus = nil - } - refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil) - } - - @objc private func openHistoryFolder() { + private func openHistoryFolder() { NSWorkspace.shared.open(ClipboardStore.storageDirectory()) } - @objc private func exportClipboardArchive() { + private func exportClipboardArchive() { let panel = NSSavePanel() panel.title = "Export ClipBored Archive" panel.nameFieldStringValue = defaultArchiveFileName() @@ -1303,7 +1286,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func importClipboardArchive() { + private func importClipboardArchive() { let panel = NSOpenPanel() panel.title = "Import ClipBored Archive" panel.canChooseFiles = true @@ -1329,7 +1312,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func pushICloudSyncArchive() { + private func pushICloudSyncArchive() { guard settings.iCloudSyncEnabled else { settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.") return @@ -1352,7 +1335,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func pullICloudSyncArchive() { + private func pullICloudSyncArchive() { guard settings.iCloudSyncEnabled else { settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.") return @@ -1400,7 +1383,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func revealICloudSyncFile() { + private func revealICloudSyncFile() { do { let url = try cloudSyncService.syncArchiveURL() if FileManager.default.fileExists(atPath: url.path) { @@ -1415,7 +1398,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } } - @objc private func clearClipboardHistory() { + private func clearClipboardHistory() { guard confirmDestructiveAction( title: "Clear Clipboard History?", message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.", @@ -1426,7 +1409,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel setDataStatus("Cleared clipboard history.") } - @objc private func clearThumbnailCache() { + private func clearThumbnailCache() { guard confirmDestructiveAction( title: "Clear Thumbnail Cache?", message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.", @@ -1437,11 +1420,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool { - #if DEBUG - if let override = debugDestructiveActionConfirmationOverride { - return override - } - #endif let alert = NSAlert() alert.messageText = title @@ -1551,447 +1529,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel return nil } - #if DEBUG - var debugWindowStyleMask: NSWindow.StyleMask { - window?.styleMask ?? [] - } - - var debugWindowMinSize: NSSize { - window?.minSize ?? .zero - } - - var debugWindowContentSize: NSSize { - window?.contentView?.bounds.size ?? .zero - } - - var debugRawSettingsTabLabels: [String] { - tabView.tabViewItems.map(\.label) - } - - func debugSetWindowContentSize(_ size: NSSize) { - window?.setContentSize(size) - window?.contentView?.layoutSubtreeIfNeeded() - } - - var debugSettingsTabLayoutMetrics: [(label: String, viewport: NSSize, document: NSSize, hasHorizontalScroller: Bool)] { - let originalSelection = tabView.selectedTabViewItem - defer { - if let originalSelection { - tabView.selectTabViewItem(originalSelection) - } - window?.contentView?.layoutSubtreeIfNeeded() - } - - return tabView.tabViewItems.map { item in - tabView.selectTabViewItem(item) - window?.contentView?.layoutSubtreeIfNeeded() - item.view?.layoutSubtreeIfNeeded() - guard let scrollView = item.view as? NSScrollView else { - return (tabTitle(for: item), .zero, .zero, true) - } - scrollView.documentView?.layoutSubtreeIfNeeded() - return ( - tabTitle(for: item), - scrollView.contentView.bounds.size, - scrollView.documentView?.frame.size ?? .zero, - scrollView.hasHorizontalScroller - ) - } - } - - var debugSettingsTabLayoutAuditMetrics: [(label: String, overflowingViewCount: Int, zeroSizedControlCount: Int)] { - let originalSelection = tabView.selectedTabViewItem - defer { - if let originalSelection { - tabView.selectTabViewItem(originalSelection) - } - window?.contentView?.layoutSubtreeIfNeeded() - } - - func visibleDescendants(of root: NSView) -> [NSView] { - var result: [NSView] = [] - func visit(_ view: NSView) { - guard !view.isHidden else { return } - result.append(view) - for subview in view.subviews { - visit(subview) - } - } - for subview in root.subviews { - visit(subview) - } - return result - } - - func shouldAudit(_ view: NSView) -> Bool { - !(view is NSScroller) - } - - func canCollapseToZero(_ view: NSView) -> Bool { - view is NSControl || view is NSTextView - } - - return tabView.tabViewItems.map { item in - tabView.selectTabViewItem(item) - window?.contentView?.layoutSubtreeIfNeeded() - item.view?.layoutSubtreeIfNeeded() - guard let scrollView = item.view as? NSScrollView, - let documentView = scrollView.documentView else { - return (tabTitle(for: item), 1, 1) - } - - documentView.layoutSubtreeIfNeeded() - let auditedViews = visibleDescendants(of: documentView).filter(shouldAudit) - let documentBounds = documentView.bounds - let overflowing = auditedViews.filter { view in - let frame = view.convert(view.bounds, to: documentView) - return frame.minX < -1 || frame.maxX > documentBounds.width + 1 - } - let zeroSizedControls = auditedViews.filter { view in - guard canCollapseToZero(view) else { return false } - let frame = view.convert(view.bounds, to: documentView) - return frame.width <= 0.5 || frame.height <= 0.5 - } - return (tabTitle(for: item), overflowing.count, zeroSizedControls.count) - } - } - - var debugSettingsTabContentPlacementMetrics: [(label: String, contentBounds: NSRect, document: NSSize)] { - let originalSelection = tabView.selectedTabViewItem - defer { - if let originalSelection { - tabView.selectTabViewItem(originalSelection) - } - window?.contentView?.layoutSubtreeIfNeeded() - } - - func visibleControlsAndLabels(of root: NSView) -> [NSView] { - var result: [NSView] = [] - func visit(_ view: NSView) { - guard !view.isHidden else { return } - if view is NSControl || view is NSTextView { - result.append(view) - } - for subview in view.subviews { - visit(subview) - } - } - for subview in root.subviews { - visit(subview) - } - return result - } - - return tabView.tabViewItems.map { item in - tabView.selectTabViewItem(item) - window?.contentView?.layoutSubtreeIfNeeded() - item.view?.layoutSubtreeIfNeeded() - guard let scrollView = item.view as? NSScrollView, - let documentView = scrollView.documentView else { - return (tabTitle(for: item), .zero, .zero) - } - - documentView.layoutSubtreeIfNeeded() - let bounds = visibleControlsAndLabels(of: documentView) - .map { $0.convert($0.bounds, to: documentView) } - .reduce(NSRect.null) { $0.union($1) } - return (tabTitle(for: item), bounds.isNull ? .zero : bounds, documentView.frame.size) - } - } - - var debugCloudSyncStatusText: String { - cloudSyncStatusLabel.stringValue - } - - var debugCloudSyncActionButtonsAreEnabled: [Bool] { - [ - iCloudSyncNowButton.isEnabled, - iCloudRestoreButton.isEnabled, - iCloudRevealButton.isEnabled - ] - } - - var debugHistoryText: String { - historyLabel.stringValue - } - - var debugHistoryStepperValue: Int { - historyStepper.integerValue - } - - var debugCacheStatusText: String { - cacheLabel.stringValue - } - - var debugCacheSliderMegabytes: Int { - Int(cacheSlider.doubleValue.rounded()) - } - - var debugDataStatusText: String { - dataStatusLabel.stringValue - } - - var debugDataStatusColor: NSColor { - dataStatusLabel.textColor ?? .clear - } - - var debugDataStatusSectionTitle: String { - guard let section = dataStatusLabel.superview as? NSStackView, - let titleLabel = section.arrangedSubviews.first as? NSTextField else { - return "" - } - return titleLabel.stringValue - } - - var debugPasteStatusText: String { - pasteStatusLabel.stringValue - } - - var debugAccessibilityStatusText: String { - accessibilityStatusLabel.stringValue - } - - var debugCaptureStatusText: String { - captureStatusLabel.stringValue - } - - var debugCaptureStatusColor: NSColor { - captureStatusLabel.textColor ?? .clear - } - - var debugStatusLabelsAllowWrapping: Bool { - [ - launchStatusLabel, - shortcutStatusLabel, - captureStatusLabel, - accessibilityStatusLabel, - pasteStatusLabel, - cacheLabel, - dataStatusLabel, - cloudSyncStatusLabel - ].allSatisfy { label in - label.lineBreakMode == .byWordWrapping - && label.maximumNumberOfLines == 0 - && !label.usesSingleLineMode - && (label.cell?.wraps ?? false) - } - } - - var debugDefaultSortTitle: String { - defaultSortPopup.selectedItem?.title ?? "" - } - - var debugPruneDuplicatesIsEnabled: Bool { - pruneDuplicatesButton.state == .on - } - - var debugKeepFirstImageIsEnabled: Bool { - keepFirstImageButton.state == .on - } - - var debugIncludeImageTextIsEnabled: Bool { - includeImageTextButton.state == .on - } - - var debugExcludeSensitiveIsEnabled: Bool { - excludeSensitiveButton.state == .on - } - - var debugClearHistoryOnQuitIsEnabled: Bool { - clearHistoryOnQuitButton.state == .on - } - - var debugLaunchAtLoginIsEnabled: Bool { - launchAtLoginButton.state == .on - } - - var debugShowMenuBarIconIsEnabled: Bool { - showMenuBarIconButton.state == .on - } - - var debugShowDockIconIsEnabled: Bool { - showDockIconButton.state == .on - } - - var debugLaunchStatusText: String { - launchStatusLabel.stringValue - } - - var debugOpenShortcutKeyText: String { - openShortcutControls?.keyField.stringValue ?? "" - } - - var debugSettingsShortcutKeyText: String { - settingsShortcutControls?.keyField.stringValue ?? "" - } - - var debugShortcutStatusText: String { - shortcutStatusLabel.stringValue - } - - var debugOpenShortcutModifierAccessibilityLabels: [String] { - guard let controls = openShortcutControls else { return [] } - return [controls.command, controls.option, controls.control, controls.shift] - .compactMap { $0.accessibilityLabel() } - } - - var debugOpenShortcutModifierAccessibilityHelps: [String] { - guard let controls = openShortcutControls else { return [] } - return [controls.command, controls.option, controls.control, controls.shift] - .compactMap { $0.accessibilityHelp() } - } - - var debugSettingsContentView: NSView? { - window?.contentView - } - - func debugSelectSettingsTab(at index: Int) { - guard index >= 0, index < tabView.numberOfTabViewItems else { return } - tabSelector.selectedSegment = index - tabView.selectTabViewItem(at: index) - window?.contentView?.layoutSubtreeIfNeeded() - } - - func debugPrepareWindowForSnapshot() { - window?.setFrameOrigin(.zero) - window?.orderFront(nil) - window?.contentView?.layoutSubtreeIfNeeded() - window?.contentView?.displayIfNeeded() - } - - var debugFullRefreshCount: Int { - debugFullRefreshCountValue - } - - var debugIgnoredAppsRefreshCount: Int { - debugIgnoredAppsRefreshCountValue - } - - var debugIgnoredAppsText: String { - ignoredAppsTextView.string - } - - var debugIgnoredAppsEditorIsFocused: Bool { - isEditingIgnoredApps - } - - @discardableResult - func debugFocusIgnoredAppsEditor() -> Bool { - textDidBeginEditing(Notification(name: NSText.didBeginEditingNotification, object: ignoredAppsTextView)) - _ = window?.makeFirstResponder(ignoredAppsTextView) - return isEditingIgnoredApps - } - - func debugSetIgnoredAppsText(_ text: String) { - ignoredAppsTextView.string = text - textDidChange(Notification(name: NSText.didChangeNotification, object: ignoredAppsTextView)) - } - - func debugSetHistoryStepperValue(_ value: Int) { - historyStepper.integerValue = value - historyLengthChanged() - } - - func debugSetCacheSliderMegabytes(_ value: Int) { - cacheSlider.doubleValue = Double(value) - cacheLimitChanged() - } - - func debugEndIgnoredAppsEditing() { - window?.makeFirstResponder(nil) - textDidEndEditing(Notification(name: NSText.didEndEditingNotification, object: ignoredAppsTextView)) - } - - func debugCloseWindow() { - windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window)) - window?.makeFirstResponder(nil) - } - - @discardableResult - func debugBeginOpenShortcutKeyEditing() -> Bool { - guard let field = openShortcutControls?.keyField else { return false } - controlTextDidBeginEditing(Notification(name: NSControl.textDidBeginEditingNotification, object: field)) - _ = window?.makeFirstResponder(field) - return isEditingShortcutKeyField(field) - } - - func debugSetOpenShortcutKeyDraft(_ text: String) { - openShortcutControls?.keyField.stringValue = text - } - - func debugEndOpenShortcutKeyEditing() { - guard let field = openShortcutControls?.keyField else { return } - window?.makeFirstResponder(nil) - controlTextDidEndEditing(Notification(name: NSControl.textDidEndEditingNotification, object: field)) - } - - func debugCommitOpenShortcutKeyText(_ text: String) { - guard let controls = openShortcutControls else { return } - controls.keyField.stringValue = text - shortcutChanged(controls.keyField) - } - - func debugSetOpenShortcutModifiers(command: Bool, option: Bool, control: Bool, shift: Bool) { - guard let controls = openShortcutControls else { return } - controls.command.state = command ? .on : .off - controls.option.state = option ? .on : .off - controls.control.state = control ? .on : .off - controls.shift.state = shift ? .on : .off - shortcutChanged(controls.command) - } - - func debugCommitSettingsShortcutKeyText(_ text: String) { - guard let controls = settingsShortcutControls else { return } - controls.keyField.stringValue = text - shortcutChanged(controls.keyField) - } - - func debugSetLaunchAtLoginEnabled(_ enabled: Bool) { - launchAtLoginButton.state = enabled ? .on : .off - launchAtLoginChanged() - } - - func debugSetShowMenuBarIconEnabled(_ enabled: Bool) { - showMenuBarIconButton.state = enabled ? .on : .off - showMenuBarIconChanged() - } - - func debugSetShowDockIconEnabled(_ enabled: Bool) { - showDockIconButton.state = enabled ? .on : .off - showDockIconChanged() - } - - func debugSetICloudSyncEnabled(_ enabled: Bool) { - iCloudSyncButton.state = enabled ? .on : .off - iCloudSyncChanged() - } - - func debugSetDestructiveActionConfirmation(_ confirmed: Bool?) { - debugDestructiveActionConfirmationOverride = confirmed - } - - func debugClearClipboardHistory() { - clearClipboardHistory() - } - - func debugClearThumbnailCache() { - clearThumbnailCache() - } - - func debugAllowedKindIsEnabled(_ kind: ClipboardItemKind) -> Bool { - allowedKindButtons.first { $0.0 == kind }?.1.state == .on - } - - func debugSetAllowedKindEnabled(_ kind: ClipboardItemKind, _ enabled: Bool) { - guard let button = allowedKindButtons.first(where: { $0.0 == kind })?.1 else { return } - button.state = enabled ? .on : .off - allowedKindChanged(button) - } - - func debugRefreshAccessibilityPermissionStatus() { - refreshAccessibilityPermissionStatus() - } - #endif } private struct ShortcutControlSet { diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index 00c8039..2208452 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -4,21 +4,6 @@ import XCTest @testable import ClipBored final class ClipboardPanelControllerTests: XCTestCase { - private var tempURLs: [URL] = [] - private var defaultsSuites: [String] = [] - - override func tearDown() { - for url in tempURLs { - try? FileManager.default.removeItem(at: url) - } - tempURLs.removeAll() - for suite in defaultsSuites { - UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) - } - defaultsSuites.removeAll() - super.tearDown() - } - func testPanelFrameUsesRightSideShelfByDefault() { let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800) let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame) @@ -31,7 +16,7 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(frames.hidden.minY, frames.shown.minY) } - func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() { + func testOpenScreenSelectionPrefersExplicitThenPointerScreen() { XCTAssertEqual( ClipboardPanelController.selectedOpenScreen( explicit: "status-item-screen", @@ -41,9 +26,6 @@ final class ClipboardPanelControllerTests: XCTestCase { ), "status-item-screen" ) - } - - func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() { XCTAssertEqual( ClipboardPanelController.selectedOpenScreen( explicit: Optional.none, @@ -141,31 +123,6 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1) } - func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() { - let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982) - let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861) - let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - - for _ in 0..<50 { - let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - XCTAssertEqual(frames.shown, first.shown) - XCTAssertEqual(frames.hidden, first.hidden) - XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1) - } - } - - func testPanelAnimationProfileStaysShortForSixtyFpsFeel() { - let profile = ClipboardPanelController.animationProfile - - XCTAssertEqual(profile.showDuration, 0.22) - XCTAssertEqual(profile.hideDuration, 0.16) - XCTAssertEqual(profile.reflowDuration, 0.18) - XCTAssertLessThanOrEqual(profile.showDuration * 60, 14) - XCTAssertLessThanOrEqual(profile.hideDuration * 60, 10) - XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 11) - XCTAssertEqual(profile.easing, .easeInEaseOut) - } - func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() { let behavior = ClipboardPanelController.panelCollectionBehavior @@ -175,69 +132,6 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertFalse(behavior.contains(.canJoinAllSpaces)) } - func testShowingPanelClearsAndCollapsesStaleSearchState() throws { - let screen = try XCTUnwrap(NSScreen.screens.first) - let (controller, _) = makeController(preferredScreen: screen) - - controller.debugSetSearchFieldText("stale query") - drainMainQueue() - XCTAssertEqual(controller.debugSearchFieldText, "stale query") - XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1) - XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips") - XCTAssertTrue(controller.debugSearchFieldIsVisible) - XCTAssertTrue(controller.debugSearchIconButtonIsVisible) - - controller.show(preferredScreen: screen) - defer { controller.hide(immediate: true) } - drainMainQueue() - - XCTAssertEqual(controller.debugSearchFieldText, "") - XCTAssertEqual(controller.debugSearchFieldWidth, 30, accuracy: 1) - XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(controller.debugSearchFieldIsVisible) - XCTAssertTrue(controller.debugSearchIconButtonIsVisible) - XCTAssertFalse(controller.debugIsSearchFieldEditing) - } - - func testReflowPlanKeepsOpenPanelOnRightSideWhenBottomDockIsVisible() { - let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) - let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845) - - let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - - XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX) - XCTAssertEqual(plan.frame.minY, screenFrame.minY) - XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY) - XCTAssertEqual(plan.frame.width, 336) - XCTAssertEqual(plan.bottomSafeInset, 20) - } - - func testReflowPlanTouchesScreenBottomWhenBottomDockIsAutoHidden() { - let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) - let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953) - - let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - - XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX) - XCTAssertEqual(plan.frame.minY, screenFrame.minY) - XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY) - XCTAssertEqual(plan.frame.width, 336) - XCTAssertEqual(plan.bottomSafeInset, 18) - } - - func testReflowPlanTracksSideDockVisibleFrameWithoutBottomInsetInflation() { - let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) - let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957) - - let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - - XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX) - XCTAssertEqual(plan.frame.minX, visibleFrame.maxX - 336) - XCTAssertEqual(plan.frame.minY, screenFrame.minY) - XCTAssertEqual(plan.frame.height, 957) - XCTAssertEqual(plan.bottomSafeInset, 18) - } - func testPanelFrameUsesConfiguredRightSideShelf() { let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) @@ -289,22 +183,20 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(frames.shown.width, 336) } - func testContentBottomInsetReservesBottomDockSpace() { + func testContentBottomInsetHandlesBottomAndSideDock() { let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) XCTAssertEqual(inset, 20) - } - - func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() { - let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) - let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957) - - let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - - XCTAssertEqual(inset, 18) + XCTAssertEqual( + ClipboardPanelController.contentBottomInset( + forScreenFrame: screenFrame, + visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957) + ), + 18 + ) } func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() { @@ -326,41 +218,38 @@ final class ClipboardPanelControllerTests: XCTestCase { } func testCommandNumberShortcutsMapToQuickPasteSlots() { - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7) - XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8) + assertShortcutMappings([ + (18, .command, 0), (19, .command, 1), (20, .command, 2), + (21, .command, 3), (23, .command, 4), (22, .command, 5), + (26, .command, 6), (28, .command, 7), (25, .command, 8) + ], using: ClipboardPanelController.quickPasteIndex) } func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() { - XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0) - XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8) - XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command)) - XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift])) + assertShortcutMappings([ + (18, [.command, .shift], 0), + (25, [.command, .shift], 8), + (18, .command, nil), + (18, [.command, .option, .shift], nil) + ], using: ClipboardPanelController.quickPastePlainTextIndex) } func testCommandOptionNumberShortcutsMapToCollections() { - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors) - XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code) - } - - func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() { - XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [])) - XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command)) - XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command)) + assertShortcutMappings([ + (18, [.command, .option], .mostRecent), + (19, [.command, .option], .mostUsed), + (20, [.command, .option], .text), + (21, [.command, .option], .links), + (23, [.command, .option], .images), + (22, [.command, .option], .files), + (26, [.command, .option], .pinned), + (28, [.command, .option], .audio), + (25, [.command, .option], .colors), + (29, [.command, .option], .code), + (18, [], nil), + (18, .command, nil), + (29, .command, nil) + ], using: ClipboardPanelController.collectionShortcutMode) } func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() { @@ -372,60 +261,35 @@ final class ClipboardPanelControllerTests: XCTestCase { } func testNavigationShortcutsMapToShelfMovement() { - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: .command), .first) - XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: .command), .last) - } - - func testNavigationShortcutsRejectUnsupportedModifiers() { - XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command)) - XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: [])) - XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: [])) - XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift)) - XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: [])) + assertShortcutMappings([ + (115, [], .first), (119, [], .last), (124, [], .next), + (121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous), + (126, .command, .first), (125, .command, .last), + (124, .command, nil), (126, [], nil), (125, [], nil), + (121, .shift, nil), (35, [], nil) + ], using: ClipboardPanelController.navigationShortcutAction) } func testSelectionShortcutsMapToRangeAndSelectAllActions() { - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: .command), .selectAll) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 115, modifiers: .shift), .extendFirst) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 119, modifiers: .shift), .extendLast) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: .shift), .extendNext) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 121, modifiers: .shift), .extendPageNext) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 116, modifiers: .shift), .extendPagePrevious) - XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 123, modifiers: .shift), .extendPrevious) - } - - func testSelectionShortcutsRequireExactModifierSets() { - XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: [])) - XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: [.command, .shift])) - XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: [])) - XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: [.command, .shift])) + assertShortcutMappings([ + (0, .command, .selectAll), + (115, .shift, .extendFirst), (119, .shift, .extendLast), + (124, .shift, .extendNext), (121, .shift, .extendPageNext), + (116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious), + (0, [], nil), (0, [.command, .shift], nil), + (124, [], nil), (124, [.command, .shift], nil) + ], using: ClipboardPanelController.selectionShortcutAction) } func testCommandActionShortcutsMapToSelectedClipActions() { - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 14, modifiers: .command), .edit) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 3, modifiers: .command), .focusSearch) - XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 45, modifiers: .command)) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .rename) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 17, modifiers: .command), .toggleCapturePause) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 6, modifiers: .command), .undoDelete) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 123, modifiers: .command), .previousCollection) - XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 124, modifiers: .command), .nextCollection) - } - - func testCommandActionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() { - XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [])) - XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift])) - XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command)) + assertShortcutMappings([ + (8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch), + (45, .command, nil), (5, .command, .showInClipboard), + (16, .command, .preview), (31, .command, .open), (15, .command, .rename), + (17, .command, .toggleCapturePause), (6, .command, .undoDelete), + (123, .command, .previousCollection), (124, .command, .nextCollection), + (8, [], nil), (8, [.command, .shift], nil), (9, .command, nil) + ], using: ClipboardPanelController.commandShortcutAction) } func testSettingsShortcutMatchesOnlyItsExactLocalBinding() { @@ -455,67 +319,32 @@ final class ClipboardPanelControllerTests: XCTestCase { } func testModifiedShortcutsMapToPanelActions() { - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText) - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack) - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .toggleStackCapture) - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText) - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection) - XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext) + assertShortcutMappings([ + (36, .shift, .pastePlainText), + (1, [.command, .shift], .toggleStack), + (8, [.command, .shift], .toggleStackCapture), + (9, [.command, .shift], .pastePlainText), + (45, [.command, .shift], .newCollection), + (36, [.command, .shift], .pasteStackNext), + (36, [], nil), (8, .shift, nil), (8, .command, nil), + (8, [.command, .option, .shift], nil), (31, [.command, .shift], nil) + ], using: ClipboardPanelController.modifiedShortcutAction) } - func testModifiedShortcutsRequireCommandShiftOnly() { - XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [])) - XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .shift)) - XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command)) - XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift])) - XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift])) - } - - private func makeController(preferredScreen: NSScreen) -> (ClipboardPanelController, ClipboardStore) { - let settings = makeSettings() - let encryptionService = ClipboardEncryptionService(keyProvider: { nil }) - let cacheService = ClipboardCacheService( - baseURL: makeTempDirectory(), - encryptionService: encryptionService - ) - let store = ClipboardStore( - settings: settings, - cacheService: cacheService, - baseURL: makeTempDirectory(), - encryptionService: encryptionService - ) - let controller = ClipboardPanelController( - store: store, - settings: settings, - cacheService: cacheService, - preferredScreen: { preferredScreen } - ) - return (controller, store) - } - - private func makeSettings() -> SettingsModel { - let suite = "com.clipbored.controllertest.\(UUID().uuidString)" - defaultsSuites.append(suite) - let defaults = UserDefaults(suiteName: suite)! - let settings = SettingsModel(defaults: defaults) - settings.maxHistoryItems = 10 - settings.historyRetention = .forever - settings.pruneDuplicates = false - return settings - } - - private func makeTempDirectory() -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("clipbored-controllertest") - .appendingPathComponent(UUID().uuidString) - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - tempURLs.append(directory) - return directory - } - - private func drainMainQueue() { - for _ in 0..<24 { - RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + private func assertShortcutMappings( + _ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)], + using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?, + file: StaticString = #filePath, + line: UInt = #line + ) { + for testCase in cases { + XCTAssertEqual( + mapping(testCase.keyCode, testCase.modifiers), + testCase.expected, + "keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)", + file: file, + line: line + ) } } } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 62ad150..45a28ef 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -39,294 +39,6 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"]) } - func testSelectingCollectionWithEmptySearchUsesIndexedVisibleItems() { - let settings = makeSettings() - settings.maxHistoryItems = 260 - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - - for index in 0..<220 { - store.upsert(makeTextItem("outside indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(index)))) - } - for index in 0..<30 { - var item = makeTextItem("client indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index))) - item.collectionName = "Client Work" - store.upsert(item) - } - store.flushPersistenceForTesting() - - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - waitForVisibleItems(in: viewModel, count: 250) - viewModel.debugResetVisibleItemsPerformanceCounters() - - viewModel.selectCollection(named: "Client Work") - - XCTAssertEqual(viewModel.visibleItems.count, 30) - XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0) - XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 1) - XCTAssertEqual(viewModel.visibleItems.first?.payload, "client indexed category 29") - } - - func testRepeatedThirtyItemCollectionSelectionsStayOnIndexedFastPath() { - let collectionCount = 60 - let itemsPerCollection = 30 - let settings = makeSettings() - settings.maxHistoryItems = collectionCount * itemsPerCollection - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - let collectionNames = (0.. String {\n return \"Hi \\(name)\"\n}", - payloadHash: hash("swift-snippet"), - createdAt: Date(timeIntervalSince1970: 200), - lastUsedAt: Date(timeIntervalSince1970: 200), - useCount: 0, - sourceApp: "Xcode", - imagePath: nil, - thumbnailPath: nil + timestamp: 300, + sourceApp: "Xcode" ) - let text = ClipboardItem( - id: UUID(), - kind: .text, - displayText: "Meeting note", - payload: "Meeting note", - payloadHash: hash("Meeting note"), - createdAt: Date(timeIntervalSince1970: 100), - lastUsedAt: Date(timeIntervalSince1970: 100), - useCount: 0, - sourceApp: "Notes", - imagePath: nil, - thumbnailPath: nil - ) - - XCTAssertEqual( - viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .code).map(\.kind), - [.code] - ) - XCTAssertEqual( - viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .text).map(\.kind), - [.code, .text] - ) - XCTAssertEqual( - viewModel.computeVisibleItems(from: [code, text], query: "type:snippet greet", sortMode: .mostRecent).map(\.kind), - [.code] - ) - } - - func testComputeVisibleItemsFiltersVideoClipsAndStructuredType() { - let settings = makeSettings() - let store = makeStore(settings: settings) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService()) - let video = ClipboardItem( - id: UUID(), + let video = makeItem( kind: .video, displayText: "Video (12 KB)", payload: "/tmp/clip.mp4", - payloadHash: hash("clip-video"), - createdAt: Date(timeIntervalSince1970: 200), - lastUsedAt: Date(timeIntervalSince1970: 200), - useCount: 0, - sourceApp: "QuickTime Player", - imagePath: nil, - thumbnailPath: nil + timestamp: 200, + sourceApp: "QuickTime Player" ) - let image = ClipboardItem( - id: UUID(), + let text = makeItem( + kind: .text, + displayText: "Meeting note", + payload: "Meeting note", + timestamp: 100, + sourceApp: "Notes" + ) + let image = makeItem( kind: .image, displayText: "Image", payload: "/tmp/image.png", - payloadHash: hash("image"), - createdAt: Date(timeIntervalSince1970: 100), - lastUsedAt: Date(timeIntervalSince1970: 100), - useCount: 0, - sourceApp: "Preview", - imagePath: nil, - thumbnailPath: nil - ) - - XCTAssertEqual( - viewModel.computeVisibleItems(from: [video, image], query: "", sortMode: .videos).map(\.kind), - [.video] - ) - XCTAssertEqual( - viewModel.computeVisibleItems(from: [video, image], query: "type:movie", sortMode: .mostRecent).map(\.kind), - [.video] - ) - XCTAssertEqual( - viewModel.computeVisibleItems(from: [video, image], query: "mp4", sortMode: .mostRecent).map(\.kind), - [.video] + timestamp: 100, + sourceApp: "Preview" ) + let items = [color, code, video, text, image] + let cases: [(ClipboardSortMode, String, [ClipboardItemKind])] = [ + (.colors, "", [.color]), + (.mostRecent, "type:swatch", [.color]), + (.mostRecent, "hex 0a84ff", [.color]), + (.code, "", [.code]), + (.text, "", [.code, .text]), + (.mostRecent, "type:snippet greet", [.code]), + (.videos, "", [.video]), + (.mostRecent, "type:movie", [.video]), + (.mostRecent, "mp4", [.video]) + ] + for (sortMode, query, expectedKinds) in cases { + XCTAssertEqual( + viewModel.computeVisibleItems(from: items, query: query, sortMode: sortMode).map(\.kind), + expectedKinds, + "sort=\(sortMode), query=\(query)" + ) + } } func testSearchMatchesIndependentTokensCaseInsensitively() { @@ -2547,7 +2184,7 @@ final class ClipboardPanelViewModelTests: XCTestCase { viewModel.searchText = "second" XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"]) - viewModel.clearSearch() + viewModel.searchText = "" viewModel.sortMode = .text XCTAssertFalse(viewModel.isStackFilterSelected) XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"]) @@ -2566,7 +2203,7 @@ final class ClipboardPanelViewModelTests: XCTestCase { viewModel.searchText = "draft" XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id]) - viewModel.clearSearch() + viewModel.searchText = "" XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note") viewModel.updateSelectedText(to: "final launch note") @@ -2634,8 +2271,8 @@ final class ClipboardPanelViewModelTests: XCTestCase { let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) waitForVisibleItems(in: viewModel, count: 2) - XCTAssertNil(viewModel.editableTextForItem(at: 0)) viewModel.selectItem(at: 0) + XCTAssertNil(viewModel.editableTextForSelected()) viewModel.updateSelectedText(to: "should not apply") XCTAssertEqual(store.items.first?.payload, file.payload) @@ -2963,18 +2600,32 @@ final class ClipboardPanelViewModelTests: XCTestCase { } private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem { + makeItem(kind: .text, displayText: text, payload: text, timestamp: createdAt.timeIntervalSince1970) + } + + private func makeItem( + kind: ClipboardItemKind, + displayText: String, + payload: String, + timestamp: TimeInterval, + lastUsedTimestamp: TimeInterval? = nil, + useCount: Int = 0, + sourceApp: String? = nil, + isPinned: Bool = false + ) -> ClipboardItem { ClipboardItem( id: UUID(), - kind: .text, - displayText: text, - payload: text, - payloadHash: hash(text), - createdAt: createdAt, - lastUsedAt: createdAt, - useCount: 0, - sourceApp: nil, + kind: kind, + displayText: displayText, + payload: payload, + payloadHash: hash(payload), + createdAt: Date(timeIntervalSince1970: timestamp), + lastUsedAt: Date(timeIntervalSince1970: lastUsedTimestamp ?? timestamp), + useCount: useCount, + sourceApp: sourceApp, imagePath: nil, - thumbnailPath: nil + thumbnailPath: nil, + isPinned: isPinned ) } @@ -3031,88 +2682,56 @@ final class ClipboardPanelViewModelTests: XCTestCase { private func makeSampleItems() -> [ClipboardItem] { [ - ClipboardItem( - id: UUID(), + makeItem( kind: .text, displayText: "Project notes", payload: "one", - payloadHash: hash("one"), - createdAt: Date(timeIntervalSince1970: 1000), - lastUsedAt: Date(timeIntervalSince1970: 1000), + timestamp: 1000, useCount: 2, - sourceApp: nil, - imagePath: nil, - thumbnailPath: nil, isPinned: true ), - ClipboardItem( - id: UUID(), + makeItem( kind: .richText, displayText: "Two", payload: "two", - payloadHash: hash("two"), - createdAt: Date(timeIntervalSince1970: 1100), - lastUsedAt: Date(timeIntervalSince1970: 1080), + timestamp: 1100, + lastUsedTimestamp: 1080, useCount: 4, - sourceApp: "Mail", - imagePath: nil, - thumbnailPath: nil, - isPinned: false + sourceApp: "Mail" ), - ClipboardItem( - id: UUID(), + makeItem( kind: .url, displayText: "Apple", payload: "https://apple.com", - payloadHash: hash("https://apple.com"), - createdAt: Date(timeIntervalSince1970: 1030), - lastUsedAt: Date(timeIntervalSince1970: 1050), + timestamp: 1030, + lastUsedTimestamp: 1050, useCount: 1, - sourceApp: "Safari", - imagePath: nil, - thumbnailPath: nil, - isPinned: false + sourceApp: "Safari" ), - ClipboardItem( - id: UUID(), + makeItem( kind: .file, displayText: "report.pdf", payload: "/tmp/report.pdf", - payloadHash: hash("/tmp/report.pdf"), - createdAt: Date(timeIntervalSince1970: 1060), - lastUsedAt: Date(timeIntervalSince1970: 1070), + timestamp: 1060, + lastUsedTimestamp: 1070, useCount: 3, - sourceApp: "Finder", - imagePath: nil, - thumbnailPath: nil, - isPinned: false + sourceApp: "Finder" ), - ClipboardItem( - id: UUID(), + makeItem( kind: .audio, displayText: "Voice memo", payload: "/tmp/voice.sound", - payloadHash: hash("/tmp/voice.sound"), - createdAt: Date(timeIntervalSince1970: 1040), - lastUsedAt: Date(timeIntervalSince1970: 1060), + timestamp: 1040, + lastUsedTimestamp: 1060, useCount: 2, - sourceApp: "Voice Memos", - imagePath: nil, - thumbnailPath: nil, - isPinned: false + sourceApp: "Voice Memos" ), - ClipboardItem( - id: UUID(), + makeItem( kind: .text, displayText: "Four", payload: "four", - payloadHash: hash("four"), - createdAt: Date(timeIntervalSince1970: 1200), - lastUsedAt: Date(timeIntervalSince1970: 1200), - useCount: 0, + timestamp: 1200, sourceApp: "Notes", - imagePath: nil, - thumbnailPath: nil, isPinned: true ) ] diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift deleted file mode 100644 index cf6306f..0000000 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ /dev/null @@ -1,3367 +0,0 @@ -import AppKit -import XCTest -@testable import ClipBored - -final class ClipboardPanelViewTests: XCTestCase { - private var tempURLs: [URL] = [] - - private struct PanelFixture { - let window: NSWindow - let view: ClipboardPanelView - let viewModel: ClipboardPanelViewModel - let settings: SettingsModel - let store: ClipboardStore - let cacheService: ClipboardCacheService - let previewProbe: PreviewProbe - } - - private final class PreviewProbe { - var requestCount = 0 - } - - override func tearDown() { - tempURLs.forEach { try? FileManager.default.removeItem(at: $0) } - tempURLs.removeAll() - super.tearDown() - } - - func testSearchFieldEditingWhenSearchFieldIsFirstResponder() { - let (window, view) = makePanelWithPanelView() - - window.makeFirstResponder(view) - XCTAssertFalse(view.isSearchFieldEditing) - - view.focusSearchField() - XCTAssertTrue(view.isSearchFieldEditing) - } - - func testSearchFieldEditingWhenFieldEditorIsFirstResponder() { - let (window, view) = makePanelWithPanelView() - - view.focusSearchField() - guard let editor = window.fieldEditor(false, for: nil) else { - return XCTFail("Expected a search field editor") - } - window.makeFirstResponder(editor) - - XCTAssertTrue(view.isSearchFieldEditing) - } - - func testSearchUsesOneLeadingAnchoredLiquidGlassControlInBothStates() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchGlassMaterial, .popover) - XCTAssertEqual(fixture.view.debugSearchGlassBlendingMode, .withinWindow) - XCTAssertEqual(fixture.view.debugSearchGlassCornerRadius, 15, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugSearchGlassBorderAlpha, 0.35) - XCTAssertGreaterThan(fixture.view.debugSearchGlassTintAlpha, 0.05) - XCTAssertTrue(fixture.view.debugSearchFieldUsesTransparentChrome) - XCTAssertTrue( - fixture.view.debugSearchIconIsPinnedToLeadingEdge, - "Icon leading offset: \(fixture.view.debugSearchIconLeadingOffset)" - ) - XCTAssertTrue( - fixture.view.debugSearchControlIsLeadingAlignedInToolbar, - "Toolbar leading offset: \(fixture.view.debugSearchControlToolbarLeadingOffset)" - ) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - - fixture.view.focusSearchField() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchGlassMaterial, .popover) - XCTAssertEqual(fixture.view.debugSearchGlassCornerRadius, 15, accuracy: 0.5) - XCTAssertTrue(fixture.view.debugSearchFieldUsesTransparentChrome) - XCTAssertTrue(fixture.view.debugSearchFieldIsCenteredInGlass) - XCTAssertTrue( - fixture.view.debugSearchControlIsLeadingAlignedInToolbar, - "Toolbar leading offset: \(fixture.view.debugSearchControlToolbarLeadingOffset)" - ) - XCTAssertTrue(fixture.view.debugSearchIconIsPinnedToLeadingEdge) - XCTAssertGreaterThanOrEqual(fixture.view.debugSearchTextLeadingGap, 4) - XCTAssertGreaterThanOrEqual(fixture.view.debugSearchEditorLeadingGap, 4) - XCTAssertEqual(fixture.view.debugSearchControlTrailingActionGap, 12, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 238, accuracy: 0.5) - } - - func testSearchFieldExpandsWhileFocusedEvenWhenEmptyAndCollapsesWhenIdle() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Search focus polish", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - - fixture.view.focusSearchField() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchControlHeight, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchIconButtonHeight, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchControlHeight, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchIconButtonHeight, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - } - - func testSearchFieldCollapsesWhenClearedWhileIdle() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Idle search collapse", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText("idle") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") - - fixture.view.debugSetSearchFieldText("") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - XCTAssertEqual(fixture.view.debugSearchIconButtonCornerRadius, 15, accuracy: 0.5) - } - - func testWhitespaceOnlySearchTextKeepsFieldExpandedUntilCleared() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Whitespace search presentation", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - - fixture.view.debugSetSearchFieldText(" ") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, " ") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Whitespace search presentation"]) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, " ") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - - XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - } - - func testPrepareForShowClearsSearchAndLeavesFieldCollapsedUntilTyping() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Quantum launch note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Alpha planning note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText("alpha") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Alpha planning note"]) - - fixture.view.prepareForShow() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Alpha planning note", "Quantum launch note"]) - - XCTAssertTrue(fixture.window.makeFirstResponder(fixture.view)) - fixture.view.debugTypeFocusedResponder("q", keyCode: 12) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "q") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum launch note"]) - } - - func testCommandFStyleSearchActionKeepsFocusInSearchField() { - let (window, view) = makePanelWithPanelView() - - window.makeFirstResponder(view) - view.focusSearch() - XCTAssertTrue(view.isSearchFieldEditing) - - view.focusSearch() - - XCTAssertTrue(view.isSearchFieldEditing) - } - - func testPlainTextEditorsOptIntoWritingToolsWhenRuntimeSupportsIt() { - let textView = ClipboardPanelView.makePlainTextEditor(initialText: "Rewrite this launch note") - - XCTAssertEqual(textView.string, "Rewrite this launch note") - XCTAssertFalse(textView.isRichText) - XCTAssertFalse(textView.importsGraphics) - XCTAssertTrue(textView.allowsUndo) - XCTAssertTrue(textView.isAutomaticSpellingCorrectionEnabled) - XCTAssertTrue(textView.isAutomaticTextReplacementEnabled) - - if textView.responds(to: NSSelectorFromString("setWritingToolsBehavior:")) { - XCTAssertEqual(textView.value(forKey: "writingToolsBehavior") as? Int, 1) - } - if textView.responds(to: NSSelectorFromString("setAllowedWritingToolsResultOptions:")) { - XCTAssertEqual(textView.value(forKey: "allowedWritingToolsResultOptions") as? Int, 1) - } - } - - func testShelfChromeKeepsToolbarAlignedAboveSideCategoryRail() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Compact shelf chrome", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugShelfChromeRowCount, 1) - XCTAssertFalse(fixture.view.debugShelfChromeContainsSearchAndCollections) - XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndActions) - XCTAssertTrue(fixture.view.debugCollectionRailIsBesideCardRail) - XCTAssertTrue(fixture.view.debugSearchFieldSharesAnimatedContainerWithIcon) - XCTAssertEqual(fixture.view.debugSearchAnimationDuration, 0.20, accuracy: 0.01) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - XCTAssertEqual(fixture.view.debugCollectionStackOrientation, .vertical) - XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 200) - XCTAssertLessThanOrEqual( - fixture.view.debugCollectionRailFrameInPanel.maxX + 8, - fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 - ) - XCTAssertEqual(fixture.view.debugUtilityToolbarGroupBackgroundAlpha, 0, accuracy: 0.01) - XCTAssertEqual(fixture.view.debugUtilityToolbarGroupCornerRadius, 14, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugToolbarButtonBackgroundAlphas.count, 2) - fixture.view.debugToolbarButtonBackgroundAlphas.forEach { alpha in - XCTAssertEqual(alpha, 0.06, accuracy: 0.01) - } - XCTAssertEqual(fixture.view.debugToolbarButtonBorderWidths, [0.5, 0.5]) - - fixture.view.debugSetSearchFieldText("type:text") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") - XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) - } - - func testCollectionRailStaysBesideCardsAcrossSelection() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Centered text category", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/centered", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "centered-image", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - assertCollectionRailIsSideIconRail(in: fixture) - - fixture.view.debugMouseDownCollectionChip(.text) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - assertCollectionRailIsSideIconRail(in: fixture) - } - - func testHoveringCategoryOnlyChangesChromeAndDoesNotMutateFiltering() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Plain text clip", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/preview", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "preview-image", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) - - fixture.view.debugHoverCollectionChip(.links) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) - - fixture.view.debugUnhoverCollectionChip(.links) - fixture.view.debugHoverCollectionChip(.text) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) - - fixture.view.debugUnhoverCollectionChip(.text) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) - } - - func testClickingHoveredCategorySelectsItWithoutHoverSideEffects() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Commit text clip", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/commit", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "commit-image", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugHoverCollectionChip(.images) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) - - fixture.view.debugMouseDownCollectionChip(.images) - fixture.view.debugUnhoverCollectionChip(.images) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Images") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.image]) - } - - func testSideShelfKeepsCardRailTuckedUnderToolbar() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 1280, height: 640), display: true) - fixture.store.upsert(makeTextItem("Tall shelf top spacing", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertLessThanOrEqual(fixture.view.debugCardRailTopGap, 120) - } - - func testSideShelfUsesRowsAndExpandsSelection() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - fixture.store.upsert(makeTextItem("Side first compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Side second compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Side third compact row", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") - XCTAssertEqual(fixture.view.debugItemsStackOrientation, .vertical) - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) - XCTAssertGreaterThan(fixture.view.debugCardSizes[0].width, fixture.view.debugCardSizes[0].height) - XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes[0].width, 240) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[0].height, 72) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[2].height, 72) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-focus", "vertical-row"]) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[0].height, 72) - XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes[1].height, 200) - } - - func testVerticalShelfUsesRowsAndExpandsSelection() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 380, height: 760), display: true) - fixture.store.upsert(makeTextItem("Vertical first compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Vertical second compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Vertical third compact row", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[0].height, 72) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[1].height, 72) - XCTAssertGreaterThan(fixture.view.debugCardSizes[1].width, fixture.view.debugCardSizes[1].height) - XCTAssertEqual(fixture.view.debugCardSizes[2].height, 56, accuracy: 0.5) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-focus", "vertical-row"]) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[0].height, 72) - XCTAssertEqual(fixture.view.debugCardSizes[1].height, 220, accuracy: 0.5) - } - - func testSideShelfAnimatesCardExpansionWhenSelectionMoves() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - fixture.store.upsert(makeTextItem("Animation first compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Animation second compact row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Animation third compact row", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) - let layoutChangeCount = fixture.view.debugAnimatedCardLayoutChangeCount - let presentationChangeCounts = fixture.view.debugCardAnimatedPresentationChangeCounts - XCTAssertEqual(fixture.view.debugCardExpansionAnimationDuration, 0.22, accuracy: 0.01) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-focus", "vertical-row"]) - XCTAssertGreaterThan(fixture.view.debugAnimatedCardLayoutChangeCount, layoutChangeCount) - XCTAssertGreaterThan(fixture.view.debugCardAnimatedPresentationChangeCounts[1], presentationChangeCounts[1]) - XCTAssertGreaterThan(fixture.view.debugCardAnimatedPresentationChangeCounts[2], presentationChangeCounts[2]) - } - - func testHoverExpansionKeepsHeaderStableAndRollsDetailBelowIt() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - fixture.store.upsert(makeTextItem("Stable header first row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Stable header second row", store: fixture.store)) - fixture.store.upsert(makeTextItem("Stable header third row", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let headerBefore = fixture.view.debugCardHeaderFramesInPanel[1] - let detailBefore = fixture.view.debugCardExpandedDetailFramesInPanel[1] - let cornerRadiusBefore = fixture.view.debugCardContentCornerRadii[1] - let followingSlotClosedY = fixture.view.debugCardSlotPresentationFramesInPanel[2].minY - let layoutChangeCount = fixture.view.debugAnimatedCardLayoutChangeCount - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) - XCTAssertEqual(detailBefore.height, 0, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugCardContentBackgroundAlphas[1], 0.5) - - fixture.view.debugHoverCard(at: 1) - RunLoop.main.run(until: Date().addingTimeInterval(0.06)) - let detailDuringAnimation = fixture.view.debugCardExpandedDetailPresentationHeights[1] - XCTAssertGreaterThan(detailDuringAnimation, detailBefore.height + 4) - XCTAssertLessThan(detailDuringAnimation, 220) - - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let headerAfter = fixture.view.debugCardHeaderFramesInPanel[1] - let detailAfter = fixture.view.debugCardExpandedDetailFramesInPanel[1] - let cornerRadiusAfter = fixture.view.debugCardContentCornerRadii[1] - let followingSlotExpandedY = fixture.view.debugCardSlotPresentationFramesInPanel[2].minY - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-focus", "vertical-row"]) - XCTAssertGreaterThan(fixture.view.debugAnimatedCardLayoutChangeCount, layoutChangeCount) - XCTAssertEqual(headerAfter.minX, headerBefore.minX, accuracy: 0.5) - XCTAssertEqual(headerAfter.minY, headerBefore.minY, accuracy: 0.5) - XCTAssertEqual(headerAfter.width, headerBefore.width, accuracy: 0.5) - XCTAssertEqual(headerAfter.height, headerBefore.height, accuracy: 0.5) - XCTAssertEqual(cornerRadiusAfter, cornerRadiusBefore, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugCardContentBackgroundAlphas[1], 0.5) - XCTAssertGreaterThan(detailAfter.height, detailBefore.height + 120) - XCTAssertTrue( - abs(detailAfter.maxY - headerAfter.minY) <= 1 - || abs(detailAfter.minY - headerAfter.maxY) <= 1 - ) - XCTAssertGreaterThan(abs(followingSlotExpandedY - followingSlotClosedY), 100) - - let layoutChangeCountAfterHover = fixture.view.debugAnimatedCardLayoutChangeCount - let detailExpandedHeight = detailAfter.height - fixture.view.debugUnhoverCard(at: 1) - RunLoop.main.run(until: Date().addingTimeInterval(0.06)) - let detailDuringClose = fixture.view.debugCardExpandedDetailPresentationHeights[1] - let followingSlotDuringCloseY = fixture.view.debugCardSlotPresentationFramesInPanel[2].minY - XCTAssertGreaterThan(detailDuringClose, 4) - XCTAssertLessThan(detailDuringClose, detailExpandedHeight - 4) - XCTAssertGreaterThan( - followingSlotDuringCloseY, - min(followingSlotClosedY, followingSlotExpandedY) + 4 - ) - XCTAssertLessThan( - followingSlotDuringCloseY, - max(followingSlotClosedY, followingSlotExpandedY) - 4 - ) - - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, []) - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) - XCTAssertGreaterThan(fixture.view.debugAnimatedCardLayoutChangeCount, layoutChangeCountAfterHover) - XCTAssertEqual(fixture.view.debugCardExpandedDetailFramesInPanel[1].height, 0, accuracy: 0.5) - } - - func testHoverDuringCollapseUsesVisualCardUnderMouseInsteadOfCollapsedTargetSlot() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - for index in 0..<8 { - fixture.store.upsert(makeTextItem("Visual hover row \(index)", store: fixture.store)) - } - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - let selectedIndexBeforeHover = fixture.viewModel.selectedIndex - - fixture.view.debugHoverCard(at: 1) - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [1]) - - fixture.view.debugUnhoverCard(at: 1) - RunLoop.main.run(until: Date().addingTimeInterval(0.06)) - let visualRowFrame = fixture.view.debugCardSlotPresentationFramesInPanel[2] - let visualRowPoint = NSPoint(x: visualRowFrame.midX, y: visualRowFrame.midY) - - fixture.view.debugHoverCard(at: 6, mouseLocationInPanel: visualRowPoint) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) - XCTAssertEqual(fixture.viewModel.selectedIndex, selectedIndexBeforeHover) - } - - func testToolbarControlsExposeVoiceOverActionHints() { - let fixture = makePanelFixture() - - XCTAssertEqual( - fixture.view.debugSearchFieldAccessibilityHelp, - "Type to search clipboard history. Use the category icons beside the cards to filter results; Command-click combines categories." - ) - XCTAssertEqual( - fixture.view.debugAddCollectionButtonAccessibilityHelp, - "Create a new Pinboard collection. Keyboard shortcut: Shift-Command-N." - ) - XCTAssertEqual( - fixture.view.debugToolbarButtonAccessibilityLabels, - ["Clear History", "Settings"] - ) - XCTAssertEqual( - fixture.view.debugToolbarButtonAccessibilityHelps, - [ - "Clear history.", - "Open ClipBored settings. Keyboard shortcut: Command-Comma." - ] - ) - - XCTAssertEqual( - fixture.view.debugToolbarButtonAccessibilityHelps, - [ - "Clear history.", - "Open ClipBored settings. Keyboard shortcut: Command-Comma." - ] - ) - } - - func testActiveVerticalCardUsesSubtleDepthWithoutTranslation() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Neighbor card chrome", store: fixture.store)) - fixture.store.upsert(makeTextItem("Active card chrome", store: fixture.store)) - drainMainQueue() - fixture.viewModel.selectItem(at: 0) - fixture.view.debugHoverCard(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertGreaterThanOrEqual( - fixture.view.debugDocumentViewFrame.height, - fixture.view.debugCardRailVisibleRect.height - 1 - ) - XCTAssertGreaterThan(fixture.view.debugFirstCardShadowOpacity, 0) - XCTAssertLessThanOrEqual(fixture.view.debugFirstCardShadowOpacity, 0.12) - XCTAssertLessThanOrEqual(fixture.view.debugFirstCardShadowRadius, 12) - XCTAssertEqual(fixture.view.debugFirstCardLayerTranslationY, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardSlotTopOffset, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSecondCardSlotTopOffset, 0, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugFirstCardSlotZPosition, fixture.view.debugSecondCardSlotZPosition) - } - - func testCapturedTextItemCreatesVisibleCardDocument() { - let fixture = makePanelFixture() - let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id]) - XCTAssertEqual(fixture.view.debugVisibleCardCount, 1) - XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack) - XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292) - XCTAssertGreaterThanOrEqual( - fixture.view.debugDocumentViewFrame.height, - fixture.view.debugCardRailVisibleRect.height - 1 - ) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) - } - - func testSingleLineTextCardsDoNotDuplicateTitleInBody() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Client follow-up note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardTextPreviewTitles, ["Client follow-up note"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewBodies, [""]) - XCTAssertEqual(fixture.view.debugCardTextPreviewChromes, ["paper-preview"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewAccentHexes, ["#F8B200"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewFadePlacements, ["none"]) - } - - func testMultiLineTextCardsShowRemainderBelowTitle() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Address:\n399 The Embarcadero\nSan Francisco", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardTextPreviewTitles, ["Address:"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewBodies, ["399 The Embarcadero San Francisco"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewChromes, ["paper-preview"]) - XCTAssertEqual(fixture.view.debugCardTextPreviewFadePlacements, ["bottom"]) - } - - func testCompactRowsFitSideShelf() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) - fixture.store.upsert(makeTextItem("Compact first", store: fixture.store)) - fixture.store.upsert(makeTextItem("Compact second", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugVisibleCardCount, 2) - XCTAssertEqual(fixture.view.debugCardSizes.count, 2) - XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row", "vertical-row"]) - XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 240) - XCTAssertLessThanOrEqual( - fixture.view.debugCardSizes.first?.width ?? 0, - fixture.view.debugCardRailVisibleRect.width - ) - XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 56, accuracy: 0.5) - XCTAssertEqual( - fixture.view.debugCardSizes.last?.width ?? 0, - fixture.view.debugCardSizes.first?.width ?? 0, - accuracy: 0.5 - ) - XCTAssertEqual(fixture.view.debugCardSizes.last?.height ?? 0, 56, accuracy: 0.5) - XCTAssertLessThanOrEqual( - fixture.view.debugDocumentViewFrame.width, - fixture.view.debugCardRailVisibleRect.width + 1 - ) - } - - func testCompactRowsPrioritizeReadableTitleOverVerboseCharacterMetric() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) - fixture.store.upsert(makeTextItem(String(repeating: "a", count: 1_234), store: fixture.store)) - fixture.store.upsert(makeTextItem("Newest expanded row", store: fixture.store)) - fixture.viewModel.selectItem(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(2), ["vertical-row", "vertical-row"]) - XCTAssertEqual(fixture.view.debugCardCompactMetricTexts, ["", ""]) - XCTAssertEqual(fixture.view.debugCardCompactMetricHiddenStates, [true, true]) - } - - func testCompactRowsKeepFileMetadataOutOfThePrimaryTitleLine() throws { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) - let fileURL = makeTempDirectory().appendingPathComponent("payload.bin") - try Data(repeating: 0x7A, count: 2_048).write(to: fileURL) - fixture.store.upsert(makeItem(kind: .file, displayText: "payload.bin", payload: fileURL.path, store: fixture.store)) - fixture.store.upsert(makeTextItem("Newest expanded row", store: fixture.store)) - fixture.viewModel.selectItem(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(2), ["vertical-row", "vertical-row"]) - XCTAssertEqual(fixture.view.debugCardCompactMetricTexts, ["", ""]) - XCTAssertEqual(fixture.view.debugCardCompactMetricHiddenStates, [true, true]) - } - - func testTallSideShelfUsesPasteStyleFocusedCard() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 640), display: true) - fixture.store.upsert(makeTextItem("Expanded shelf preview", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row"]) - XCTAssertLessThanOrEqual( - fixture.view.debugCardSizes.first?.width ?? 0, - fixture.view.debugCardRailVisibleRect.width - ) - XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 56, accuracy: 0.5) - XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 72) - } - - func testVerticalPanelLayoutStacksCardsAndScrollsVertically() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 520, height: 900), display: true) - for index in 0..<12 { - fixture.store.upsert(makeTextItem("Vertical shelf clip \(index)", store: fixture.store)) - } - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") - XCTAssertEqual(fixture.view.debugItemsStackOrientation, .vertical) - XCTAssertTrue(fixture.view.debugResizeHandleIsHidden) - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertGreaterThan(fixture.view.debugDocumentViewFrame.height, fixture.view.debugCardRailVisibleRect.height) - XCTAssertLessThanOrEqual(fixture.view.debugDocumentViewFrame.width, fixture.view.debugCardRailVisibleRect.width + 1) - } - - func testVerticalShelfUsesSideCategoryRailAndFullHeightCards() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Vertical switch note", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/vertical", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "vertical-image", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") - XCTAssertEqual(fixture.view.bounds.width, 336, accuracy: 1) - XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 300) - XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 240) - XCTAssertLessThanOrEqual( - fixture.view.debugCollectionRailFrameInPanel.maxX + 8, - fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 - ) - } - - func testCardsShowQuickPasteNumberBadgesForFirstNineItems() { - let fixture = makePanelFixture() - for index in 0..<10 { - fixture.store.upsert(makeTextItem("Quick paste badge \(index)", store: fixture.store)) - drainMainQueue() - } - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugVisibleCardCount, 10) - XCTAssertEqual(fixture.view.debugQuickPasteBadgeTexts, ["1", "2", "3", "4", "5", "6", "7", "8", "9"]) - } - - func testCardFooterHidesMissingSourceInsteadOfShowingUnknown() { - let fixture = makePanelFixture() - var item = makeTextItem("No source noise", store: fixture.store) - item.sourceApp = nil - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFirstCardFooterSourceIsHidden) - XCTAssertEqual(fixture.view.debugFirstCardFooterSourceText, "") - XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "15 characters") - } - - func testCardFooterShowsSourceAndUsageWhenUsed() { - let fixture = makePanelFixture() - var item = makeTextItem("Frequently pasted", store: fixture.store) - item.useCount = 3 - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugFirstCardFooterSourceIsHidden) - XCTAssertEqual(fixture.view.debugFirstCardFooterSourceText, "Ghostty - Used 3 times") - } - - func testCardFooterShowsRemoteDeviceContextWhenAvailable() { - let fixture = makePanelFixture() - var item = makeTextItem("Copied on another Mac", store: fixture.store) - item.sourceApp = "Safari" - item.sourceDeviceName = "Studio Mac" - item.useCount = 1 - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugFirstCardFooterSourceIsHidden) - XCTAssertEqual(fixture.view.debugFirstCardFooterSourceText, "Safari on Studio Mac - Used once") - } - - func testCardFooterShowsRemoteDeviceWithoutSourceApp() { - let fixture = makePanelFixture() - var item = makeTextItem("Device-only clip", store: fixture.store) - item.sourceApp = nil - item.sourceDeviceName = "MacBook Pro" - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugFirstCardFooterSourceIsHidden) - XCTAssertEqual(fixture.view.debugFirstCardFooterSourceText, "MacBook Pro") - } - - func testEditedTextStatusUsesActionTone() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Editable footer item", store: fixture.store)) - drainMainQueue() - - fixture.viewModel.updateSelectedText(to: "Edited footer item") - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Updated text clip") - } - - func testValidationActionsPublishUsefulStatusMessages() { - let fixture = makePanelFixture() - - fixture.viewModel.addVisibleItemsToStack() - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "No visible clips to stack") - - fixture.store.upsert(makeTextItem("Stack warning note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.addSelectedItemsToStack() - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Added 1 selected clip to Stack") - - fixture.viewModel.addSelectedItemsToStack() - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Selected clips are already in Stack") - } - - func testPanelShellUsesTransparentLiquidGlassSurface() { - let (_, view) = makePanelWithPanelView() - drainMainQueue() - view.layoutSubtreeIfNeeded() - - XCTAssertEqual(view.debugPanelMaterial, .hudWindow) - XCTAssertEqual(view.debugPanelCornerRadius, 14) - XCTAssertEqual(view.debugPanelShadowOpacity, 0) - XCTAssertGreaterThan(view.debugPanelSurfaceAlpha, 0.10) - XCTAssertLessThan(view.debugPanelSurfaceAlpha, 0.25) - XCTAssertGreaterThan(view.debugPanelBorderAlpha, 0.30) - } - - func testWritesPasteStyleVisualSnapshotWhenRequested() throws { - guard ProcessInfo.processInfo.environment["CLIPBORED_WRITE_VISUAL_SNAPSHOT"] == "1" else { - throw XCTSkip("Set CLIPBORED_WRITE_VISUAL_SNAPSHOT=1 to write a visual panel snapshot.") - } - - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - - var code = makeItem( - kind: .code, - displayText: "Swift Formatter", - payload: "func paste(_ item: ClipboardItem) {\n clipboard.write(item)\n}", - store: fixture.store - ) - code.sourceApp = "Xcode" - var color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store) - color.sourceApp = "Sketch" - var audio = makeItem(kind: .audio, displayText: "Tame Impala", payload: "/tmp/tame-impala.sound", store: fixture.store) - audio.sourceApp = "Music" - let image = makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService) - var text = makeTextItem("Address:\n399 The Embarcadero\nSan Francisco, CA 94105\nUnited States", store: fixture.store) - text.sourceApp = "Mail" - let linkID = UUID() - let linkPaths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: linkID)) - let link = ClipboardItem( - id: linkID, - kind: .url, - displayText: "OMA's office furniture line", - payload: "https://kvellhome.com/lookbook", - payloadHash: fixture.store.hashString("https://kvellhome.com/lookbook"), - createdAt: Date(timeIntervalSinceNow: -180), - lastUsedAt: Date(timeIntervalSinceNow: -180), - useCount: 0, - sourceApp: "Safari", - imagePath: linkPaths.full, - thumbnailPath: linkPaths.thumb - ) - - for item in [code, color, audio, image, text, link].reversed() { - fixture.store.upsert(item) - } - fixture.viewModel.selectItem(at: 0) - if ProcessInfo.processInfo.environment["CLIPBORED_VISUAL_SNAPSHOT_EXPAND_SEARCH"] == "1" { - fixture.view.debugExpandSearchPresentationForSnapshot() - } - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.view.displayIfNeeded() - - let output = URL(fileURLWithPath: ProcessInfo.processInfo.environment["CLIPBORED_VISUAL_SNAPSHOT_PATH"] ?? "build/visual-snapshots/paste-style-panel.png") - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - try writeSnapshot(of: fixture.view, to: output) - } - - func testOpeningTransitionDefersCardRailReloadUntilAnimationCompletes() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Existing clip", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugVisibleCardCount, 1) - - fixture.view.beginOpeningTransition() - XCTAssertTrue(fixture.view.debugIsDeferringVisualReloads) - fixture.store.upsert(makeTextItem("New clip during open", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugVisibleCardCount, 1) - - fixture.view.finishOpeningTransition() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugIsDeferringVisualReloads) - XCTAssertEqual(fixture.view.debugVisibleCardCount, 2) - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels.first, "Text: New clip during open") - } - - func testFocusedCardKeepsKeyboardFocusWhenVisibleItemsReloadAboveIt() { - let fixture = makePanelFixture() - var existing = makeTextItem("Keep keyboard focus", store: fixture.store) - existing.createdAt = Date(timeIntervalSince1970: 100) - existing.lastUsedAt = existing.createdAt - fixture.store.upsert(existing) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Keep keyboard focus") - - var newer = makeTextItem("Newer clip above focus", store: fixture.store) - newer.createdAt = Date(timeIntervalSince1970: 200) - newer.lastUsedAt = newer.createdAt - fixture.store.upsert(newer) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Newer clip above focus", "Keep keyboard focus"]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugActiveCardIndexes, [1]) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Keep keyboard focus") - } - - func testHoverExpansionDoesNotStealKeyboardSelectionOrNavigation() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Oldest hover note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Middle hover note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Newest hover note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Newest hover note") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - - fixture.view.debugHoverCard(at: 2) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Newest hover note") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - XCTAssertEqual(fixture.view.debugActiveCardIndexes, [2]) - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-focus"]) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") - XCTAssertEqual(fixture.view.debugCardVisibleActionLabels(at: 2), []) - - fixture.view.debugPressFocusedResponderKeyCode(125) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Middle hover note") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugActiveCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, []) - XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-focus", "vertical-row"]) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") - XCTAssertEqual(fixture.view.debugCardVisibleActionLabels(at: 2), []) - - fixture.view.debugRefreshHoverCardWithoutMouseMovement(at: 2) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Middle hover note") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, []) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") - - fixture.view.debugHoverCard(at: 2) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Middle hover note") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") - } - - func testCollectionRailUsesCompactSideIconsAndTracksSelection() { - let fixture = makePanelFixture() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCollectionTitles, ["Clipboard"]) - XCTAssertEqual(fixture.view.debugCollectionLeadingSymbols, ["doc.on.clipboard"]) - XCTAssertEqual(fixture.view.debugCollectionChipWidths.count, 1) - XCTAssertEqual(fixture.view.debugCollectionChipWidths.first ?? 0, 34, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionChipLabelHiddenStates, [true]) - XCTAssertEqual(fixture.view.debugCollectionChipCountLabelHiddenStates, [true]) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCollectionTitles, ["Clipboard", "Frequent", "Links"]) - XCTAssertEqual(fixture.view.debugCollectionChipLabelHiddenStates, [true, true, true]) - XCTAssertEqual(fixture.view.debugCollectionChipCountLabelHiddenStates, [true, true, true]) - - fixture.view.debugMouseDownCollectionChip(.links) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - } - - func testMouseSelectingCollectionChipMovesFocusAndLeavesSingleSelection() { - let fixture = makePanelFixture() - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Clipboard"]) - - fixture.view.debugMouseDownCollectionChip(.links) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Links"]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Links"]) - } - - func testCommandClickingCategoryChipsCombinesFilters() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "image payload", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugHoverCollectionChip(.text) - fixture.view.debugCommandMouseDownCollectionChip(.text) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Text category note"]) - - fixture.view.debugHoverCollectionChip(.links) - fixture.view.debugCommandMouseDownCollectionChip(.links) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text", "Links"]) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/category", "Text category note"]) - } - - func testSelectingCategoryLeavesOnlyTheNewCategoryVisuallyHighlighted() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - XCTAssertGreaterThan(fixture.view.debugCollectionChipBackgroundAlpha(.mostRecent), 0.4) - - fixture.view.debugMouseDownCollectionChip(.text) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) - XCTAssertLessThanOrEqual(fixture.view.debugCollectionChipBackgroundAlpha(.mostRecent), 0.1) - XCTAssertGreaterThan(fixture.view.debugCollectionChipBackgroundAlpha(.text), 0.4) - } - - func testOpeningShortcutModifiersDoNotAccidentallyCombineCategorySelection() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugMouseDownCollectionChip(.mostUsed) - fixture.view.debugMouseDownCollectionChip(.text, modifiers: [.command, .shift]) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) - - fixture.view.debugMouseDownCollectionChip(.links, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text", "Links"]) - } - - func testCommandSpaceCombinesFocusedCategoryChips() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.text)) - fixture.view.debugPressFocusedResponderKeyCode(49, modifiers: .command) - drainMainQueue() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) - fixture.view.debugPressFocusedResponderKeyCode(49, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text", "Links"]) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/category", "Text category note"]) - } - - func testCollectionRailChipsAreKeyboardFocusableAndVoiceOverDescriptive() { - let fixture = makePanelFixture() - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugCollectionChipAcceptsFirstResponder, - Array(repeating: true, count: fixture.view.debugCollectionTitles.count) - ) - XCTAssertEqual( - fixture.view.debugCollectionCountLabelHiddenStates, - [true, true, true] - ) - XCTAssertEqual(fixture.view.debugCollectionChipAccessibilityLabels.first, "Clipboard, selected, 1 clip") - XCTAssertEqual( - fixture.view.debugCollectionChipAccessibilityHelps.first, - "Press Return or Space to show Clipboard; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories." - ) - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostUsed)) - fixture.view.debugPressFocusedResponderWithSpace() - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Frequent") - XCTAssertTrue(fixture.view.debugCollectionChipAccessibilityLabels.contains("Frequent, selected, 1 clip")) - XCTAssertTrue( - fixture.view.debugCollectionChipAccessibilityHelps.contains( - "Press Return or Space to show Frequent; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories." - ) - ) - - fixture.view.debugMouseDownCollectionChip(.links) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - } - - func testCollectionRailUsesHorizontalArrowsForChipsAndVerticalArrowsForCards() { - let fixture = makePanelFixture() - var clientItem = makeTextItem("Client collection item", store: fixture.store) - clientItem.collectionName = "Client Work" - fixture.store.upsert(clientItem) - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) - fixture.store.upsert(makeItem(kind: .image, text: "image payload", store: fixture.store)) - fixture.store.upsert(makeTextItem("Stack queue item", store: fixture.store)) - drainMainQueue() - - fixture.viewModel.selectItem(at: 0) - fixture.viewModel.toggleSelectedStackMembership() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - - fixture.view.debugPressFocusedResponderKeyCode(124) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Frequent"]) - - fixture.view.debugPressFocusedResponderKeyCode(123) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) - - fixture.view.debugPressFocusedResponderKeyCode(125) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.viewModel.selectedIndex, 1) - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - fixture.view.debugPressFocusedResponderKeyCode(119) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Stack"]) - - fixture.view.debugPressFocusedResponderKeyCode(115) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) - } - - func testTypingFromFocusedCollectionChipStartsSearch() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum reference", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - fixture.view.debugTypeFocusedResponder("q", keyCode: 12) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "q") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"]) - } - - func testTypingFromFocusedCollectionChipAppendsSearchTokenBoundary() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum reference", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.view.debugSetSearchFieldText("type:text") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - fixture.view.debugTypeFocusedResponder("q", keyCode: 12) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "type:text q") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"]) - } - - func testKeyboardCancelClearsSearchFromFocusedCollectionChip() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum reference", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.view.debugSetSearchFieldText("quantum") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"]) - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) - - XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference", "Alpha note"]) - } - - func testCollectionRailAddButtonCreatesEmptyCollection() { - let fixture = makePanelFixture() - - XCTAssertTrue(fixture.view.debugCollectionRailContainsAddButton) - XCTAssertTrue(fixture.view.debugAddCollectionButtonIsEnabled) - - fixture.view.debugSetCollectionNameProvider { " Research Stack " } - fixture.view.debugPressAddCollectionButton() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Created Research Stack") - XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Research Stack"]) - XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [0]) - XCTAssertEqual(fixture.view.debugCustomCollectionCountLabelHiddenStates, [true]) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Research Stack") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Research Stack"]) - XCTAssertEqual(fixture.view.debugVisibleCardCount, 0) - XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No clips in Research Stack") - XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Drag clips here or use Collect to add them.") - } - - func testToolbarDoesNotExposeNewTextClipControl() { - let fixture = makePanelFixture() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugToolbarButtonAccessibilityLabels.contains("New text clip")) - XCTAssertEqual(fixture.view.debugToolbarButtonAccessibilityLabels, ["Clear History", "Settings"]) - } - - func testClearHistoryMenuClearsRecentRangesAndAllTime() { - let fixture = makePanelFixture() - var older = makeTextItem("Older than a day", store: fixture.store) - older.createdAt = Date().addingTimeInterval(-90_000) - older.lastUsedAt = older.createdAt - var recent = makeTextItem("Recent clip", store: fixture.store) - recent.createdAt = Date().addingTimeInterval(-3_600) - recent.lastUsedAt = recent.createdAt - fixture.store.upsert(older) - fixture.store.upsert(recent) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugClearHistoryMenuTitles, ["Past Hour", "Past Day", "All Time"]) - - fixture.view.debugPerformClearHistoryMenuItem(titled: "Past Day") - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Older than a day"]) - XCTAssertEqual(fixture.viewModel.statusMessage, "Cleared 1 clip") - - fixture.view.debugPerformClearHistoryMenuItem(titled: "All Time") - drainMainQueue() - - XCTAssertTrue(fixture.viewModel.visibleItems.isEmpty) - XCTAssertEqual(fixture.viewModel.statusMessage, "Cleared 1 clip") - } - - func testCollectionFilteredCardsUseStoredCollectionHeaderColor() { - let fixture = makePanelFixture() - fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8") - var item = makeTextItem("Collect this note", store: fixture.store) - item.collectionName = "Research Stack" - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"]) - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Collect this note") - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • Just now") - XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#0A9EB8") - XCTAssertEqual(fixture.view.debugCustomCollectionColorHexes["Research Stack"], "#0A9EB8") - XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") - } - - func testCompactCardsUseFullColorHeaderSurface() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Full color card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardHeaderSurfaceColorHex, - fixture.view.debugFirstCardHeaderColorHex - ) - XCTAssertEqual(fixture.view.debugFirstCardHeaderSurfaceAlpha, 0.96, accuracy: 0.01) - } - - func testCardHeaderUsesPasteStyleRelativeAgeText() { - let fixture = makePanelFixture() - var item = makeTextItem("Readable age", store: fixture.store) - item.createdAt = Date().addingTimeInterval(-3 * 60) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Readable age") - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • 3 minutes ago") - - fixture.viewModel.createCollection(named: "Age Stack", colorHex: "#FF3B30", selectAfterCreate: false) - fixture.viewModel.assignSelected(to: "Age Stack") - fixture.viewModel.selectCollection(named: "Age Stack") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • 3 minutes ago") - } - - func testCollectionChipsExposeManagementMenuActions() { - let fixture = makePanelFixture() - fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugCustomCollectionMenuTitles(named: "Research Stack"), - ["Edit Collection...", "Export Pinboard...", "-", "Delete Collection"] - ) - XCTAssertEqual( - fixture.view.debugCustomCollectionAccessibilityHelp(named: "Research Stack"), - "Press Return or Space to show Research Stack; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories. Open the context menu to edit, export, or delete this Pinboard." - ) - } - - func testCollectionChipManagementRenamesAndDeletesCollections() { - let fixture = makePanelFixture() - fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8") - var item = makeTextItem("Collect this note", store: fixture.store) - item.collectionName = "Research Stack" - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCustomCollectionChip(named: "Research Stack")) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Research Stack"]) - - fixture.view.debugEditCollection(named: "Research Stack", to: "Product Research", colorHex: "#3366FF") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Product Research"]) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Product Research") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Product Research"]) - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Collect this note") - XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF") - - fixture.view.debugDeleteCollection(named: "Product Research") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCustomCollectionTitles, []) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), []) - XCTAssertEqual(fixture.store.items.map(\.payload), []) - } - - func testSelectedCardActionsRespectSelectedKind() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Plain text", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 0) - XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) - XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeIsHidden) - XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-focus"]) - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - - fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.selectFirstItem() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 0) - XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) - XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeIsHidden) - XCTAssertEqual(fixture.view.debugCardPresentations.first, "vertical-focus") - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - } - - func testLargeCollectionReloadKeepsHiddenCardActionsLazy() { - let fixture = makePanelFixture() - fixture.settings.maxHistoryItems = 60 - fixture.settings.ensureCollection(named: "Client Work") - - fixture.view.beginOpeningTransition() - for index in 0..<30 { - var item = makeTextItem("Client clip \(index)", store: fixture.store) - item.collectionName = "Client Work" - fixture.store.upsert(item) - } - drainMainQueue() - fixture.view.finishOpeningTransition() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardItemCount, 30) - XCTAssertLessThan(fixture.view.debugCardSlotCount, 30) - XCTAssertGreaterThan(fixture.view.debugCardSlotCount, 0) - XCTAssertLessThan(fixture.view.debugVisibleCardCount, 30) - XCTAssertGreaterThan(fixture.view.debugVisibleCardCount, 0) - XCTAssertEqual(fixture.view.debugConstructedActionButtonCount, 0) - - fixture.viewModel.selectCollection(named: "Client Work") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.count, 30) - XCTAssertEqual(fixture.view.debugCardItemCount, 30) - XCTAssertLessThan(fixture.view.debugCardSlotCount, 30) - XCTAssertGreaterThan(fixture.view.debugCardSlotCount, 0) - XCTAssertLessThan(fixture.view.debugVisibleCardCount, 30) - XCTAssertGreaterThan(fixture.view.debugVisibleCardCount, 0) - XCTAssertLessThan(fixture.view.debugLastSynchronousCardRenderCount, 30) - XCTAssertGreaterThan(fixture.view.debugLastSynchronousCardRenderCount, 0) - XCTAssertEqual(fixture.view.debugConstructedActionButtonCount, 0) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - - fixture.view.debugScrollCardRailVertically(deltaY: -10_000) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minY, 0) - XCTAssertEqual(fixture.view.debugCardItemCount, 30) - XCTAssertLessThan(fixture.view.debugCardSlotCount, 30) - XCTAssertLessThan(fixture.view.debugVisibleCardCount, 30) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertEqual(fixture.view.debugConstructedActionButtonCount, 0) - } - - func testCompactFileCardActionsFitInsideShelfWithOverflowMenu() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 0) - XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-focus"]) - XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeIsHidden) - XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 520) - XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 220, accuracy: 0.5) - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - } - - func testCardsAreKeyboardFocusableAndReturnPastesFocusedCard() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Older text card", store: fixture.store)) - fixture.store.upsert(makeTextItem("Newest text card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAcceptsFirstResponder, [true, true]) - XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Older text card") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugCardBorderWidths[1], 0.8, accuracy: 0.01) - XCTAssertEqual(fixture.view.debugCardAccessibilityValues[1], "Selected") - XCTAssertEqual(fixture.view.debugCardAccessibilityHelps[1], "Press Return to paste. Space opens Quick Look. Command-R renames; Command-E edits text. Delete removes selected clips; Command-Z restores them.") - - fixture.view.debugPressFocusedResponderWithReturn() - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Copied") - } - - func testFocusedCardCommandShortcutsOpenRenameAndEditFlows() { - let fixture = makePanelFixture() - fixture.view.debugSuppressClipEditDialogs() - fixture.store.upsert(makeTextItem("Shortcut editable text", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(15, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugRenameClipRequestCount, 1) - XCTAssertEqual(fixture.view.debugEditClipRequestCount, 0) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Shortcut editable text") - - fixture.view.debugPressFocusedResponderKeyCode(14, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugRenameClipRequestCount, 1) - XCTAssertEqual(fixture.view.debugEditClipRequestCount, 1) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Shortcut editable text") - } - - func testFocusedNonTextCardIgnoresCommandEditShortcut() { - let fixture = makePanelFixture() - fixture.view.debugSuppressClipEditDialogs() - fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(14, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugEditClipRequestCount, 0) - XCTAssertEqual(fixture.view.debugRenameClipRequestCount, 0) - } - - func testFocusedCardDeleteRemovesSelectionAndCommandZRestoresIt() { - let fixture = makePanelFixture() - var oldest = makeTextItem("Oldest focused delete", store: fixture.store) - oldest.createdAt = Date(timeIntervalSince1970: 100) - oldest.lastUsedAt = oldest.createdAt - var middle = makeTextItem("Middle focused delete", store: fixture.store) - middle.createdAt = Date(timeIntervalSince1970: 200) - middle.lastUsedAt = middle.createdAt - var newest = makeTextItem("Newest focused delete", store: fixture.store) - newest.createdAt = Date(timeIntervalSince1970: 300) - newest.lastUsedAt = newest.createdAt - fixture.store.upsert(oldest) - fixture.store.upsert(middle) - fixture.store.upsert(newest) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.selectItem(at: 0) - fixture.viewModel.selectItem(at: 2, mode: .toggle) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 2]) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(51) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Middle focused delete"]) - XCTAssertEqual(fixture.viewModel.statusMessage, "Deleted 2 clips") - XCTAssertEqual(fixture.viewModel.statusMessage, "Deleted 2 clips") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.view.debugPressFocusedResponderKeyCode(6, modifiers: .command) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.viewModel.visibleItems.map(\.payload), - ["Newest focused delete", "Middle focused delete", "Oldest focused delete"] - ) - XCTAssertEqual(fixture.viewModel.statusMessage, "Restored 2 clips") - XCTAssertEqual(fixture.viewModel.statusMessage, "Restored 2 clips") - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 2]) - } - - func testFocusedCardCopyAndPlainTextPasteShortcutsUseSelection() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Focused shortcut payload", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(8, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Copied") - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Focused shortcut payload") - - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.view.debugPressFocusedResponderKeyCode(36, modifiers: .shift) - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Copied Plain Text") - - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.view.debugPressFocusedResponderKeyCode(9, modifiers: [.command, .shift]) - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Copied Plain Text") - } - - func testFocusedCardCommandPreviewOpenAndShowInClipboardShortcuts() { - let fixture = makePanelFixture() - fixture.view.debugSuppressClipOpenActions() - let url = makeItem(kind: .url, displayText: "Release notes", payload: "https://example.com/releases", store: fixture.store) - var note = makeTextItem("Filtered release note", store: fixture.store) - note.createdAt = Date(timeIntervalSince1970: 100) - note.lastUsedAt = note.createdAt - fixture.store.upsert(note) - fixture.store.upsert(url) - fixture.view.debugSetSearchFieldText("release") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/releases", "Filtered release note"]) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(16, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.previewProbe.requestCount, 1) - - fixture.view.debugPressFocusedResponderKeyCode(31, modifiers: .command) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugOpenClipRequestCount, 1) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/releases") - - fixture.view.debugPressFocusedResponderKeyCode(5, modifiers: .command) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugShowInClipboardRequestCount, 1) - XCTAssertEqual(fixture.viewModel.statusMessage, "Showing in Clipboard") - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/releases") - } - - func testFocusedTextCardSpaceOpensQuickLookInsteadOfPasting() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Preview this text without pasting", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - - fixture.view.debugPressFocusedResponderWithSpace() - drainMainQueue() - - XCTAssertEqual(fixture.previewProbe.requestCount, 1) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Preview this text without pasting") - XCTAssertEqual(fixture.viewModel.statusMessage, "") - } - - func testTypingFromFocusedCardStartsSearch() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.view.debugTypeFocusedResponder("q", keyCode: 12) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "q") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card"]) - } - - func testTypingFromFocusedCardAppendsSearchTokenBoundary() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.view.debugSetSearchFieldText("type:text") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.view.debugTypeFocusedResponder("q", keyCode: 12) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "type:text q") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card"]) - } - - func testKeyboardCancelClearsSearchFromFocusedCard() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Alpha note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Quantum card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.view.debugSetSearchFieldText("quantum") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card"]) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum card", "Alpha note"]) - XCTAssertFalse(fixture.view.clearSearchForKeyboardCancel()) - } - - func testKeyboardCancelClearsWhitespaceOnlySearchBeforeClosing() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Whitespace search note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText(" ") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, " ") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Whitespace search note"]) - - XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Whitespace search note"]) - XCTAssertFalse(fixture.view.clearSearchForKeyboardCancel()) - } - - func testFocusedPreviewableCardSpaceOpensQuickLook() { - let fixture = makePanelFixture() - fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugCardAccessibilityHelps.first, "Press Return to paste. Space opens Quick Look. Command-R renames; Command-E edits text. Delete removes selected clips; Command-Z restores them.") - - fixture.view.debugPressFocusedResponderWithSpace() - drainMainQueue() - - XCTAssertEqual(fixture.previewProbe.requestCount, 1) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/read") - } - - func testFocusedCardsSupportShelfNavigationKeys() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) - - for index in 0..<8 { - fixture.store.upsert(makeTextItem("Keyboard navigation item \(index)", store: fixture.store)) - drainMainQueue() - } - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.viewModel.selectFirstItem() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let pageStep = fixture.view.debugVisibleCardPageStep - XCTAssertGreaterThan(pageStep, 1) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - - fixture.view.debugPressFocusedResponderKeyCode(124) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - - fixture.view.debugPressFocusedResponderKeyCode(125) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 1) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - - fixture.view.debugPressFocusedResponderKeyCode(121) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, min(7, 1 + pageStep)) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [fixture.viewModel.selectedIndex]) - - fixture.view.debugPressFocusedResponderKeyCode(119) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 7) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [7]) - - fixture.view.debugPressFocusedResponderKeyCode(116) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, max(0, 7 - pageStep)) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [fixture.viewModel.selectedIndex]) - - fixture.view.debugPressFocusedResponderKeyCode(115) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - - fixture.view.debugPressFocusedResponderKeyCode(126) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - - fixture.view.debugPressFocusedResponderKeyCode(125, modifiers: .shift) - drainMainQueue() - XCTAssertEqual(fixture.viewModel.selectedIndex, 1) - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 1]) - XCTAssertEqual(fixture.view.debugActiveCardIndexes, [1]) - - fixture.view.debugPressFocusedResponderKeyCode(0, modifiers: .command) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, Array(0..<8)) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - } - - func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() { - let fixture = makePanelFixture() - var item = makeItem(kind: .url, text: "https://example.com", store: fixture.store) - item.sourceApp = nil - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardHeaderBadgeSymbols, ["link"]) - XCTAssertEqual(fixture.view.debugCardHeaderBadgeTexts, [""]) - XCTAssertTrue(fixture.view.debugFirstCardHeaderMaskedCorners.contains(.layerMinXMaxYCorner)) - XCTAssertTrue(fixture.view.debugFirstCardHeaderMaskedCorners.contains(.layerMaxXMaxYCorner)) - XCTAssertFalse(fixture.view.debugFirstCardHeaderMaskedCorners.contains(.layerMinXMinYCorner)) - XCTAssertFalse(fixture.view.debugFirstCardHeaderMaskedCorners.contains(.layerMaxXMinYCorner)) - XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeMaskedCorners.contains(.layerMinXMinYCorner)) - XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeMaskedCorners.contains(.layerMinXMaxYCorner)) - XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeMaskedCorners.contains(.layerMaxXMaxYCorner)) - XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeMaskedCorners.contains(.layerMaxXMinYCorner)) - XCTAssertGreaterThanOrEqual( - fixture.view.debugFirstCardHeaderBadgeContentFrame.width, - fixture.view.debugFirstCardHeaderBadgeFrame.width * 0.55 - ) - } - - func testCardHeaderSourceAppIconFillsBadgeTile() throws { - let fixture = makePanelFixture() - var item = makeTextItem("Copied from Finder", store: fixture.store) - item.sourceApp = "Finder" - item.sourceAppBundleId = "com.apple.finder" - guard NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.finder") != nil else { - throw XCTSkip("Finder app icon is unavailable in this environment.") - } - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let badgeFrame = fixture.view.debugFirstCardHeaderBadgeFrame - let contentFrame = fixture.view.debugFirstCardHeaderBadgeContentFrame - XCTAssertEqual(fixture.view.debugCardHeaderBadgeTexts, [""]) - XCTAssertLessThan(contentFrame.minX, badgeFrame.minX) - XCTAssertLessThan(contentFrame.minY, badgeFrame.minY) - XCTAssertGreaterThan(contentFrame.maxX, badgeFrame.maxX) - XCTAssertGreaterThan(contentFrame.maxY, badgeFrame.maxY) - XCTAssertEqual(contentFrame.midX, badgeFrame.midX, accuracy: 0.5) - XCTAssertEqual(contentFrame.midY, badgeFrame.midY, accuracy: 0.5) - } - - func testCardHeaderUsesSourceMonogramWhenAppIconIsUnavailable() { - let fixture = makePanelFixture() - var item = makeTextItem("Copied from a named app", store: fixture.store) - item.sourceApp = "Arc Browser" - - fixture.store.upsert(item) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugCardHeaderBadgeTexts, ["AB"]) - } - - func testCollectionRailShowsLiveCounts() { - let fixture = makePanelFixture() - var pinned = makeTextItem("Pinned note", store: fixture.store) - pinned.isPinned = true - let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store) - let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store) - let image = makeItem(kind: .image, text: "image payload", store: fixture.store) - let color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store) - let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store) - let video = makeItem(kind: .video, text: "/tmp/movie.mp4", store: fixture.store) - let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store) - let code = makeItem( - kind: .code, - displayText: "Swift Snippet", - payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", - store: fixture.store - ) - - [pinned, rich, link, image, color, audio, video, file, code].forEach { - fixture.store.upsert($0) - drainMainQueue() - } - - XCTAssertEqual(fixture.viewModel.visibleItems.count, 9) - XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) - let countSummary = fixture.viewModel.collectionCountSummary() - XCTAssertEqual(ClipboardSortMode.allCases.map { countSummary.count(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) - XCTAssertEqual(fixture.view.debugCollectionCounts, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) - XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: true, count: 11)) - } - - func testCollectionRailShowsAssignedCollections() { - let fixture = makePanelFixture() - var link = makeItem(kind: .url, text: "https://example.com/read", store: fixture.store) - link.collectionName = "Useful Links" - var note = makeTextItem("Meeting note", store: fixture.store) - note.collectionName = "Important Notes" - var file = makeItem(kind: .file, text: "/tmp/client-brief.pdf", store: fixture.store) - file.collectionName = "Client Work" - - fixture.store.upsert(link) - fixture.store.upsert(note) - fixture.store.upsert(file) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Useful Links", "Important Notes", "Client Work"]) - XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [1, 1, 1]) - XCTAssertEqual(fixture.view.debugCustomCollectionCountLabelHiddenStates, [true, true, true]) - let countSummary = fixture.viewModel.collectionCountSummary() - XCTAssertEqual(["Useful Links", "Important Notes", "Client Work"].map { countSummary.count(named: $0) }, [1, 1, 1]) - - fixture.viewModel.selectCollection(named: "Useful Links") - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"]) - } - - func testCardsCanDropOntoCollectionChipsToOrganize() { - let fixture = makePanelFixture() - var existing = makeTextItem("Existing client note", store: fixture.store) - existing.collectionName = "Client Work" - fixture.store.upsert(existing) - let dropped = makeTextItem("Drop this note", store: fixture.store) - fixture.store.upsert(dropped) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Client Work"]) - XCTAssertEqual(fixture.view.debugCustomCollectionDropTargets, ["Client Work"]) - XCTAssertEqual(fixture.viewModel.visibleItems.first?.id, dropped.id) - - fixture.view.debugDropFirstCard(onCollectionNamed: "Client Work") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Client Work") - XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [2]) - - fixture.viewModel.selectCollection(named: "Client Work") - drainMainQueue() - - XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.payload)), ["Existing client note", "Drop this note"]) - } - - func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let names = [ - "Client Work", - "Research Archive", - "Launch Planning", - "Design QA", - "Product References", - "Reading Stack", - "Invoices", - "Hiring Pipeline" - ] - - for name in names { - var item = makeTextItem("Collection item \(name)", store: fixture.store) - item.collectionName = name - fixture.store.upsert(item) - } - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) - XCTAssertFalse(fixture.view.debugCollectionRailHasHorizontalScroller) - XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 300) - XCTAssertGreaterThan( - fixture.view.debugCollectionRailContentFrameInPanel.height, - fixture.view.debugCollectionRailVisibleRect.height - ) - XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work")) - XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) - - XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) - fixture.view.debugScrollCollectionRailVertically(deltaY: -220) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minY, 0) - - fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) - } - - func testSelectionScrollsCardRailToKeepSelectedCardVisible() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) - - for index in 0..<8 { - fixture.store.upsert(makeTextItem("Scrollable clipboard item \(index)", store: fixture.store)) - drainMainQueue() - } - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.selectFirstItem() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minY, 1) - - fixture.viewModel.selectItem(at: fixture.viewModel.visibleItems.count - 1) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let visibleRect = fixture.view.debugCardRailVisibleRect - let selectedFrame = fixture.view.debugSelectedCardFrameInDocument - XCTAssertGreaterThan(visibleRect.minY, 0) - XCTAssertLessThanOrEqual(selectedFrame.minY, visibleRect.maxY) - XCTAssertGreaterThanOrEqual(visibleRect.maxY + 1, selectedFrame.maxY) - - fixture.viewModel.selectItem(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minY, 1) - } - - func testVerticalWheelPansSideCardListAndClamps() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) - - for index in 0..<8 { - fixture.store.upsert(makeTextItem("Wheel scroll item \(index)", store: fixture.store)) - drainMainQueue() - } - fixture.window.contentView?.layoutSubtreeIfNeeded() - fixture.viewModel.selectItem(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertGreaterThan( - fixture.view.debugCardRailDocumentHeight, - fixture.view.debugCardRailVisibleRect.height + 1 - ) - XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minY, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeWidth, 26, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) - - fixture.view.debugScrollCardRailVertically(deltaY: -60) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minY, 0) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) - - fixture.view.debugScrollCardRailVertically(deltaY: -10_000) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - let maxOffset = fixture.view.debugCardRailDocumentHeight - fixture.view.debugCardRailVisibleRect.height - XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minY, maxOffset, accuracy: 1) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) - - fixture.view.debugScrollCardRailVertically(deltaY: 10_000) - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minY, 0, accuracy: 1) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) - } - - func testFilteredEmptyStateNamesCurrentCollection() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store)) - drainMainQueue() - - fixture.viewModel.sortMode = .images - drainMainQueue() - - XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No images yet") - XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Image clips are saved when the clipboard contains image data.") - } - - func testEmptySearchResultsUseSearchSpecificStatusCopy() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText("missing") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugVisibleCardCount, 0) - XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No matching clips") - XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Try a broader search or switch filters.") - } - - func testPinnedEmptyStatePointsToPinAction() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store)) - drainMainQueue() - - fixture.viewModel.sortMode = .pinned - drainMainQueue() - - XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No pinned clips") - XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Use the Pin action on a card to keep important clips here.") - } - - func testCardsExposeContextMenuActions() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Context menu text", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - XCTAssertEqual( - fixture.view.debugFirstCardCollectionMenuTitles, - ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."] - ) - XCTAssertEqual( - fixture.view.debugFirstCardCollectActionMenuTitles, - ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."] - ) - XCTAssertEqual( - fixture.view.debugFirstCardCaptureRuleMenuTitles, - ["Ignore Ghostty", "Ignore Text Items"] - ) - } - - func testCaptureRuleActionsPublishUsefulStatusMessages() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Capture rule status tone", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugPerformFirstCardCaptureRuleMenuItem(titled: "Ignore Ghostty") - drainMainQueue() - - XCTAssertTrue(fixture.settings.ignoredApps.contains("Ghostty")) - XCTAssertEqual(fixture.viewModel.statusMessage, "Ignored Ghostty for future captures") - - fixture.view.debugPerformFirstCardCaptureRuleMenuItem(titled: "Ignore Text Items") - drainMainQueue() - - XCTAssertTrue(fixture.settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) - XCTAssertEqual(fixture.viewModel.statusMessage, "Ignored Text items for future captures") - } - - func testFilteredCardsExposeShowInClipboardContextMenuAction() { - let fixture = makePanelFixture() - var release = makeTextItem("Release needle", store: fixture.store) - release.createdAt = Date(timeIntervalSince1970: 100) - release.lastUsedAt = release.createdAt - var meeting = makeTextItem("Meeting note", store: fixture.store) - meeting.createdAt = Date(timeIntervalSince1970: 200) - meeting.lastUsedAt = meeting.createdAt - fixture.store.upsert(release) - fixture.store.upsert(meeting) - drainMainQueue() - - fixture.viewModel.searchText = "release" - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - - fixture.view.debugShowFirstCardInClipboard() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Meeting note", "Release needle"]) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Release needle") - } - - func testShowInClipboardCollapsesClearedUISearchFieldWhenIdle() { - let fixture = makePanelFixture() - var release = makeTextItem("Release needle", store: fixture.store) - release.createdAt = Date(timeIntervalSince1970: 100) - release.lastUsedAt = release.createdAt - var meeting = makeTextItem("Meeting note", store: fixture.store) - meeting.createdAt = Date(timeIntervalSince1970: 200) - meeting.lastUsedAt = meeting.createdAt - fixture.store.upsert(release) - fixture.store.upsert(meeting) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText("release") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Release needle"]) - XCTAssertEqual(fixture.view.debugSearchFieldText, "release") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugShowFirstCardInClipboard() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Meeting note", "Release needle"]) - XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Release needle") - } - - func testModelSideSearchClearSynchronizesSearchFieldPresentation() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Launch note", store: fixture.store)) - fixture.store.upsert(makeTextItem("Meeting note", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.view.debugSetSearchFieldText("launch") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, "launch") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Launch note"]) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - fixture.viewModel.clearSearch() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Meeting note", "Launch note"]) - } - - func testPreviewableCardsExposeQuickLookContextMenuAction() { - let fixture = makePanelFixture() - fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - XCTAssertEqual( - fixture.view.debugFirstCardCaptureRuleMenuTitles, - ["Ignore Ghostty", "Ignore File Items"] - ) - } - - func testImageCardsExposeImageQuickActions() { - let fixture = makePanelFixture() - fixture.store.upsert(makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Rotate Image", "Extract Text", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - } - - func testMultiSelectedCardsExposeBatchActionsAndSelectionCount() { - let fixture = makePanelFixture() - var oldest = makeTextItem("Oldest multi-select note", store: fixture.store) - oldest.createdAt = Date(timeIntervalSince1970: 100) - oldest.lastUsedAt = oldest.createdAt - var middle = makeTextItem("Middle multi-select note", store: fixture.store) - middle.createdAt = Date(timeIntervalSince1970: 200) - middle.lastUsedAt = middle.createdAt - var newest = makeTextItem("Newest multi-select note", store: fixture.store) - newest.createdAt = Date(timeIntervalSince1970: 300) - newest.lastUsedAt = newest.createdAt - fixture.store.upsert(oldest) - fixture.store.upsert(middle) - fixture.store.upsert(newest) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.selectItem(at: 0) - fixture.viewModel.selectItem(at: 2, mode: .toggle) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 2]) - XCTAssertEqual(fixture.view.debugActiveCardIndexes, []) - XCTAssertEqual(fixture.view.debugCardAccessibilityValues, ["Selected", "Not selected", "Selected"]) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Paste Selection")) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Copy Selection")) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Paste Selection as Text")) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Copy Selection as Text")) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Add Selection to Stack")) - - fixture.view.debugPerformFirstCardMenuItem(titled: "Add Selection to Stack") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Added 2 selected clips to Stack") - XCTAssertEqual(fixture.view.debugStackChipCount, 2) - } - - func testStackedCardsExposeStackManagementActions() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Stackable text", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.toggleSelectedStackMembership() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardMenuTitles, - ["Paste", "Copy", "Rename...", "Remove from Stack", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"] - ) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - } - - func testStackMembershipUpdatesCardsWithoutRebuildingThem() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Older stack item", store: fixture.store)) - fixture.store.upsert(makeTextItem("Newest stack item", store: fixture.store)) - drainMainQueue() - fixture.viewModel.selectItem(at: 0) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugFirstCardVisibleActionLabels.contains("Add to Stack")) - let cardIdentifiersBeforeStackToggle = fixture.view.debugCardObjectIdentifiers - - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.toggleSelectedStackMembership() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Stack") - XCTAssertEqual(fixture.view.debugCardObjectIdentifiers, cardIdentifiersBeforeStackToggle) - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Remove from Stack")) - XCTAssertTrue(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStackChipCount, 1) - - fixture.viewModel.selectItem(at: 1) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardObjectIdentifiers, cardIdentifiersBeforeStackToggle) - } - - func testStackChipAppearsFiltersAndClearsWithStack() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store)) - fixture.store.upsert(makeTextItem("Second stack chip item", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugStackChipIsVisible) - - fixture.viewModel.selectItem(at: 1) - fixture.viewModel.toggleSelectedStackMembership() - fixture.viewModel.selectItem(at: 0) - fixture.viewModel.toggleSelectedStackMembership() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStackChipCount, 2) - XCTAssertFalse(fixture.view.debugStackChipIsSelected) - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Clipboard"]) - - fixture.view.debugPressStackChip() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") - XCTAssertTrue(fixture.view.debugStackChipIsSelected) - XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, []) - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["First stack chip item", "Second stack chip item"]) - - fixture.viewModel.clearStack() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStackChipCount, 0) - } - - func testStackCaptureModeShowsEmptyActiveStackChip() { - let fixture = makePanelFixture() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.view.debugStackChipIsVisible) - - fixture.viewModel.toggleStackCaptureMode() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStackChipCount, 0) - XCTAssertEqual(fixture.view.debugStackChipMenuTitles.first, "Stop Stack Capture") - XCTAssertEqual(fixture.viewModel.statusMessage, "Stack capture is on") - - fixture.view.debugPressStackChip() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") - XCTAssertTrue(fixture.view.debugStackChipIsSelected) - XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "Stack capture is on") - XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Copied items will appear here in order.") - - fixture.view.debugToggleStackCaptureFromStackChip() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertFalse(fixture.viewModel.isStackCaptureEnabled) - XCTAssertFalse(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.viewModel.statusMessage, "Stack capture is off") - } - - func testStackChipMenuAddsVisibleShelfToQueue() { - let fixture = makePanelFixture() - var older = makeTextItem("Older batch stack item", store: fixture.store) - older.createdAt = Date(timeIntervalSince1970: 100) - older.lastUsedAt = older.createdAt - var middle = makeTextItem("Middle batch stack item", store: fixture.store) - middle.createdAt = Date(timeIntervalSince1970: 200) - middle.lastUsedAt = middle.createdAt - var newest = makeTextItem("Newest batch stack item", store: fixture.store) - newest.createdAt = Date(timeIntervalSince1970: 300) - newest.lastUsedAt = newest.createdAt - fixture.store.upsert(older) - fixture.store.upsert(middle) - fixture.store.upsert(newest) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - fixture.viewModel.selectItem(at: 0) - fixture.viewModel.toggleSelectedStackMembership() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStackChipCount, 1) - XCTAssertEqual( - fixture.view.debugStackChipMenuTitles, - ["Start Stack Capture", "-", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack"] - ) - XCTAssertEqual( - fixture.view.debugStackChipAccessibilityHelp, - "Press Return or Space to show Stack; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories. Open the context menu for Stack capture and Stack paste actions." - ) - - fixture.view.debugAddVisibleClipsToStackFromStackChip() - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugStackChipCount, 3) - XCTAssertEqual(fixture.viewModel.statusMessage, "Added 2 clips to Stack") - - fixture.view.debugPressStackChip() - drainMainQueue() - - XCTAssertEqual( - fixture.viewModel.visibleItems.map(\.payload), - ["Newest batch stack item", "Middle batch stack item", "Older batch stack item"] - ) - } - - func testCollectionMenuOffersExistingCustomCollections() { - let fixture = makePanelFixture() - var existing = makeTextItem("Existing client note", store: fixture.store) - existing.collectionName = "Client Work" - fixture.store.upsert(existing) - fixture.store.upsert(makeTextItem("Unsorted card", store: fixture.store)) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual( - fixture.view.debugFirstCardCollectionMenuTitles, - ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."] - ) - XCTAssertEqual( - fixture.view.debugFirstCardCollectActionMenuTitles, - ["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."] - ) - } - - func testBottomSafeInsetIsAppliedToPanelContent() { - let fixture = makePanelFixture() - - fixture.view.setBottomSafeInset(108) - - XCTAssertEqual(fixture.view.debugContentInsets.bottom, 108) - } - - func testInternalLookingTextDoesNotBecomePrimaryCardTitle() { - let fixture = makePanelFixture() - let item = makeTextItem("clipbored-flow-test-\(UUID().uuidString)", store: fixture.store) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Text: Copied text"]) - } - - func testLinkCardsUseReadableTitleAndAddressPreview() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .url, - displayText: "Release notes", - payload: "https://www.example.com/releases/v1?utm_source=copy", - store: fixture.store - ) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Release notes"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Release notes|example.com/releases/v1|example.com"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-site-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewChrome, "browser-card") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewHostText, "example.com") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewMonogram, "EC") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewAccentHex, "#008FFB") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewHeroHeight, 78, accuracy: 0.5) - } - - func testPlainURLCardsDeriveReadableTitleFromPath() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .url, - displayText: "https://www.example.com/articles/weekly-design-review?utm_source=copy", - payload: "https://www.example.com/articles/weekly-design-review?utm_source=copy", - store: fixture.store - ) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Weekly Design Review"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Weekly Design Review|example.com/articles/weekly-design-review|example.com"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-site-preview"]) - } - - func testCompactLinkCardUsesCondensedBrowserPreview() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let item = makeItem( - kind: .url, - displayText: "Weekly Design Review", - payload: "https://www.example.com/articles/weekly-design-review", - store: fixture.store - ) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-site-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewChrome, "browser-card") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewHostText, "example.com") - XCTAssertEqual(fixture.view.debugFirstCardLinkPreviewHeroHeight, 78, accuracy: 0.5) - } - - func testLinkCardsUseMediaPreviewWhenThumbnailExists() throws { - let fixture = makePanelFixture() - let id = UUID() - let paths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: id)) - let item = ClipboardItem( - id: id, - kind: .url, - displayText: "Lookbook", - payload: "https://example.com/lookbook", - payloadHash: fixture.store.hashString("https://example.com/lookbook"), - createdAt: Date(), - lastUsedAt: Date(), - useCount: 0, - sourceApp: "Safari", - imagePath: paths.full, - thumbnailPath: paths.thumb - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .links - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Lookbook"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Lookbook|example.com/lookbook|example.com"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-media-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardLinkMediaPreviewChrome, "thumbnail-card") - XCTAssertEqual(fixture.view.debugFirstCardLinkMediaPreviewHostText, "example.com") - XCTAssertEqual(fixture.view.debugFirstCardLinkMediaPreviewImageHeight, 80, accuracy: 0.5) - } - - func testTallSideShelfKeepsLinkMediaCardCompact() throws { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 1280, height: 640), display: true) - let id = UUID() - let paths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: id)) - let item = ClipboardItem( - id: id, - kind: .url, - displayText: "Lookbook", - payload: "https://example.com/lookbook", - payloadHash: fixture.store.hashString("https://example.com/lookbook"), - createdAt: Date(), - lastUsedAt: Date(), - useCount: 0, - sourceApp: "Safari", - imagePath: paths.full, - thumbnailPath: paths.thumb - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .links - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-media-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardLinkMediaPreviewChrome, "thumbnail-card") - XCTAssertEqual(fixture.view.debugFirstCardLinkMediaPreviewImageHeight, 80, accuracy: 0.5) - } - - func testRichTextCardsUseDisplayTextInsteadOfManagedPayloadPath() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .richText, - displayText: "Styled note", - payload: "/tmp/clipbored-managed-rich-text.rtf", - store: fixture.store - ) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Rich Text: Styled note"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Styled note|Styled note|11 characters"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["rich-text-preview"]) - } - - func testFileCardsUseFilenameLocationAndType() { - let fixture = makePanelFixture() - let fileURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Documents") - .appendingPathComponent("Project Plan.pdf") - let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Project Plan.pdf"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Project Plan.pdf|~/Documents|PDF"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewChrome, "document-cover") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewExtensionText, "PDF") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewLocationPlacement, "below-cover") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewCoverSize, NSSize(width: 104, height: 74)) - } - - func testCompactFileCardsUseCondensedDocumentCover() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let fileURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Desktop") - .appendingPathComponent("Summary.txt") - let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewChrome, "document-cover") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewExtensionText, "TXT") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewCoverSize, NSSize(width: 104, height: 74)) - } - - func testRenamedClipsUseCustomTitleInCardsAndSearch() { - let fixture = makePanelFixture() - let fileURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Documents") - .appendingPathComponent("Project Plan.pdf") - let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Rename...")) - fixture.view.debugRenameFirstCard(to: " Client Launch Brief ") - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Client Launch Brief"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Client Launch Brief|~/Documents|PDF"]) - XCTAssertEqual(fixture.viewModel.statusMessage, "Renamed clip") - - fixture.viewModel.searchText = "launch" - drainMainQueue() - - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id]) - } - - func testMultipleFileCardsUseCountAndSharedLocation() throws { - let fixture = makePanelFixture() - let directory = makeTempDirectory() - let firstURL = directory.appendingPathComponent("Brief.pdf") - let secondURL = directory.appendingPathComponent("Invoice.csv") - try Data("brief".utf8).write(to: firstURL) - try Data("invoice".utf8).write(to: secondURL) - let item = makeItem( - kind: .file, - displayText: "2 files", - payload: FilePayload.payload(from: [firstURL, secondURL]), - store: fixture.store - ) - - fixture.store.upsert(item) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["multi-file-preview"]) - } - - func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws { - let fixture = makePanelFixture() - let imageURL = makeTempDirectory().appendingPathComponent("Campaign Reference.png") - let imageData = try XCTUnwrap(sampleImage().pngData()) - try imageData.write(to: imageURL) - let item = makeItem(kind: .file, displayText: "File", payload: imageURL.path, store: fixture.store) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .files - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Campaign Reference.png"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-media-preview"]) - } - - func testPdfAndImageCardsUseSpecificPreviewText() { - let fixture = makePanelFixture() - let pdfURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Downloads") - .appendingPathComponent("Reference Guide.pdf") - let pdf = makeItem( - kind: .pdf, - displayText: "PDF", - payload: pdfURL.path, - store: fixture.store, - ocrText: "Quarterly metrics\nSecond page" - ) - - fixture.store.upsert(pdf) - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["PDF: Reference Guide.pdf"]) - XCTAssertEqual( - fixture.view.debugCardPreviewSummaries, - ["Reference Guide.pdf|Quarterly metrics Second page|PDF"] - ) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewChrome, "document-cover") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewExtensionText, "PDF") - XCTAssertEqual(fixture.view.debugFirstCardFilePreviewLocationPlacement, "below-cover") - - let image = makeItem( - kind: .image, - displayText: "Image", - payload: "", - store: fixture.store, - ocrText: "Receipt total $42" - ) - fixture.store.upsert(image) - fixture.viewModel.sortMode = .images - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Receipt total $42"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Receipt total $42|Receipt total $42|OCR text"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-fallback-preview"]) - } - - func testImageCardsUseMediaPreviewWhenThumbnailExists() { - let fixture = makePanelFixture() - let item = makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .images - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Campaign portrait"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["media-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardMediaMetricText, "360 x 280") - XCTAssertEqual(fixture.view.debugFirstCardMediaMetricPlacement, "bottom-center") - } - - func testAudioCardsUseSpecificPreviewText() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .audio, - displayText: "Audio (14 KB)", - payload: "/tmp/clipbored-audio.sound", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .audio - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Audio: Audio (14 KB)"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Audio (14 KB)|Sound clip|Audio"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardAudioPreviewChrome, "album-card") - XCTAssertEqual(fixture.view.debugFirstCardAudioArtworkSize, 62, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardAudioLabelPlacement, "below-artwork") - } - - func testCompactAudioCardsUseCondensedAlbumArtwork() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let item = makeItem( - kind: .audio, - displayText: "Audio (14 KB)", - payload: "/tmp/clipbored-audio.sound", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .audio - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardAudioPreviewChrome, "album-card") - XCTAssertEqual(fixture.view.debugFirstCardAudioArtworkSize, 62, accuracy: 0.5) - } - - func testVideoCardsUseFilmPreview() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .video, - displayText: "Video (24 KB)", - payload: "/tmp/clipbored-video.mp4", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .videos - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Video: Video (24 KB)"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Video (24 KB)|Video clip|MP4"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardVideoPreviewChrome, "player-card") - XCTAssertEqual(fixture.view.debugFirstCardVideoFrameHeight, 82, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardVideoFormatPlacement, "bottom-center") - } - - func testCompactVideoCardsUseCondensedPlayerPreview() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let item = makeItem( - kind: .video, - displayText: "Video (24 KB)", - payload: "/tmp/clipbored-video.mp4", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .videos - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardVideoPreviewChrome, "player-card") - XCTAssertEqual(fixture.view.debugFirstCardVideoFrameHeight, 82, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardVideoFormatPlacement, "bottom-center") - } - - func testVideoCardsUseMediaPreviewWhenThumbnailExists() throws { - let cacheService = ClipboardCacheService( - baseURL: makeTempDirectory(), - encryptionService: ClipboardEncryptionService(keyProvider: { nil }), - videoThumbnailProvider: { _ in self.sampleImage() } - ) - let fixture = makePanelFixture(cacheService: cacheService) - let id = UUID() - let path = try XCTUnwrap(cacheService.cacheVideo(Data([0, 1, 2, 3]), id: id, fileExtension: "mp4")) - let item = ClipboardItem( - id: id, - kind: .video, - displayText: "Video (24 KB)", - payload: path, - payloadHash: fixture.store.hashString("video-thumbnail"), - createdAt: Date(), - lastUsedAt: Date(), - useCount: 0, - sourceApp: "QuickTime Player", - imagePath: nil, - thumbnailPath: nil - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .videos - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Video: Video (24 KB)"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Video (24 KB)|Video clip|MP4"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-media-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardVideoPreviewChrome, "thumbnail-player") - XCTAssertEqual(fixture.view.debugFirstCardVideoFormatPlacement, "bottom-center") - } - - func testColorCardsUseSwatchPreview() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .color, - displayText: "#0A84FF", - payload: "#0A84FF", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .colors - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Color: #0A84FF"]) - XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["#0A84FF|RGB 10 132 255|Color"]) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["color-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewChrome, "paint-chip") - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewHexText, "#0A84FF") - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewSwatchPlacement, "top") - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewChipSize, NSSize(width: 196, height: 98)) - } - - func testCompactColorCardsUseCondensedPaintChipPreview() { - let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) - let item = makeItem( - kind: .color, - displayText: "#FF3B30", - payload: "#FF3B30", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .colors - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["color-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewChrome, "paint-chip") - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewHexText, "#FF3B30") - XCTAssertEqual(fixture.view.debugFirstCardColorPreviewChipSize, NSSize(width: 196, height: 98)) - } - - func testCodeCardsUseMonospaceSnippetPreview() { - let fixture = makePanelFixture() - let item = makeItem( - kind: .code, - displayText: "Swift Snippet", - payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", - store: fixture.store - ) - - fixture.store.upsert(item) - fixture.viewModel.sortMode = .code - drainMainQueue() - fixture.window.contentView?.layoutSubtreeIfNeeded() - - XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Code: Swift Snippet"]) - XCTAssertEqual( - fixture.view.debugCardPreviewSummaries, - ["Swift Snippet|func greet(name: String) -> String { return \"Hi \\(name)\" }|Swift"] - ) - XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["code-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) - drainMainQueue() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - } - - private func assertCollectionRailIsSideIconRail(in fixture: PanelFixture, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5, file: file, line: line) - XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 200, file: file, line: line) - XCTAssertEqual(fixture.view.debugCollectionStackOrientation, .vertical, file: file, line: line) - XCTAssertLessThanOrEqual( - fixture.view.debugCollectionRailFrameInPanel.maxX + 8, - fixture.view.debugSelectedCardFrameInPanel.minX + 0.5, - file: file, - line: line - ) - } - - private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) { - let fixture = makePanelFixture() - return (fixture.window, fixture.view) - } - - private func makePanelFixture(cacheService: ClipboardCacheService? = nil) -> PanelFixture { - let settings = makeSettings() - let cacheService = cacheService ?? ClipboardCacheService( - baseURL: makeTempDirectory(), - encryptionService: ClipboardEncryptionService(keyProvider: { nil }) - ) - let store = makeStore(settings: settings, cacheService: cacheService) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - let previewProbe = PreviewProbe() - - let view = ClipboardPanelView( - viewModel: viewModel, - onClose: {}, - onSettings: {}, - onPreview: { previewProbe.requestCount += 1 } - ) - - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 1200, height: 520), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.contentView = view - window.makeKeyAndOrderFront(nil) - return PanelFixture( - window: window, - view: view, - viewModel: viewModel, - settings: settings, - store: store, - cacheService: cacheService, - previewProbe: previewProbe - ) - } - - private func makeSettings() -> SettingsModel { - let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.viewtest.\(UUID().uuidString)")!) - settings.maxHistoryItems = 10 - settings.historyRetention = .forever - settings.pruneDuplicates = false - return settings - } - - private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore { - ClipboardStore( - settings: settings, - cacheService: cacheService, - baseURL: makeTempDirectory(), - encryptionService: ClipboardEncryptionService(keyProvider: { nil }) - ) - } - - private func makeTempDirectory() -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("clipbored-viewtest") - .appendingPathComponent(UUID().uuidString) - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - tempURLs.append(directory) - return directory - } - - private func makeTextItem(_ text: String, store: ClipboardStore) -> ClipboardItem { - makeItem(kind: .text, text: text, store: store) - } - - private func makeItem(kind: ClipboardItemKind, text: String, store: ClipboardStore) -> ClipboardItem { - makeItem(kind: kind, displayText: text, payload: text, store: store) - } - - private func makeItem( - kind: ClipboardItemKind, - displayText: String, - payload: String, - store: ClipboardStore, - ocrText: String? = nil - ) -> ClipboardItem { - ClipboardItem( - id: UUID(), - kind: kind, - displayText: displayText, - payload: payload, - payloadHash: store.hashString(payload), - createdAt: Date(), - lastUsedAt: Date(), - useCount: 0, - sourceApp: "Ghostty", - imagePath: nil, - thumbnailPath: nil, - ocrText: ocrText - ) - } - - private func makeCachedImageItem(store: ClipboardStore, cacheService: ClipboardCacheService) -> ClipboardItem { - let id = UUID() - let paths = cacheService.cacheImage(sampleImage(), id: id) - return ClipboardItem( - id: id, - kind: .image, - displayText: "Campaign portrait", - payload: paths?.full ?? "", - payloadHash: store.hashString("campaign-portrait"), - createdAt: Date(), - lastUsedAt: Date(), - useCount: 0, - sourceApp: "Photos", - imagePath: paths?.full, - thumbnailPath: paths?.thumb, - ocrText: nil - ) - } - - private func sampleImage() -> NSImage { - let image = NSImage(size: NSSize(width: 180, height: 140)) - image.lockFocus() - NSColor(calibratedRed: 1.0, green: 0.76, blue: 0.20, alpha: 1).setFill() - NSRect(x: 0, y: 0, width: 180, height: 140).fill() - NSColor(calibratedRed: 0.92, green: 0.20, blue: 0.26, alpha: 1).setFill() - NSBezierPath(ovalIn: NSRect(x: 34, y: 24, width: 82, height: 82)).fill() - NSColor(calibratedRed: 0.05, green: 0.42, blue: 0.86, alpha: 1).setFill() - NSBezierPath(roundedRect: NSRect(x: 92, y: 45, width: 58, height: 48), xRadius: 12, yRadius: 12).fill() - image.unlockFocus() - return image - } - - private func localDate(_ value: String, hour: Int = 12, minute: Int = 0) -> Date { - var calendar = Calendar(identifier: .gregorian) - calendar.locale = Locale(identifier: "en_US_POSIX") - calendar.timeZone = .autoupdatingCurrent - let formatter = DateFormatter() - formatter.calendar = calendar - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = calendar.timeZone - formatter.dateFormat = "yyyy-MM-dd" - let date = formatter.date(from: value)! - return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date)! - } - - private func writeSnapshot(of view: NSView, to url: URL) throws { - view.layoutSubtreeIfNeeded() - view.displayIfNeeded() - let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) - rep.size = view.bounds.size - view.cacheDisplay(in: view.bounds, to: rep) - let image = NSImage(size: view.bounds.size) - image.addRepresentation(rep) - let tiff = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiff)) - let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - try png.write(to: url) - } - - private func drainMainQueue() { - for _ in 0..<20 { - RunLoop.main.run(until: Date().addingTimeInterval(0.01)) - } - } -} diff --git a/tests/clipboredtests/DiagnosticsServiceTests.swift b/tests/clipboredtests/DiagnosticsServiceTests.swift deleted file mode 100644 index a107994..0000000 --- a/tests/clipboredtests/DiagnosticsServiceTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import XCTest -@testable import ClipBored - -final class DiagnosticsServiceTests: XCTestCase { - func testCountersCanBeIncrementedAndReset() { - let diagnostics = DiagnosticsService.shared - diagnostics.reset() - - diagnostics.incrementMonitorTick() - diagnostics.incrementPasteboardChange() - diagnostics.incrementExtractionAttempt() - diagnostics.incrementDatabaseMutation() - diagnostics.incrementCachePurge() - - // The snapshot read synchronizes with the serial diagnostics queue. - let snapshot = diagnostics.currentSnapshot() - XCTAssertEqual(snapshot.monitorTicks, 1) - XCTAssertEqual(snapshot.pasteboardChanges, 1) - XCTAssertEqual(snapshot.extractionAttempts, 1) - XCTAssertEqual(snapshot.databaseMutations, 1) - XCTAssertEqual(snapshot.cachePurges, 1) - - diagnostics.reset() - XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0)) - } -} diff --git a/tests/clipboredtests/LinkPreviewWindowControllerTests.swift b/tests/clipboredtests/LinkPreviewWindowControllerTests.swift deleted file mode 100644 index 89a508a..0000000 --- a/tests/clipboredtests/LinkPreviewWindowControllerTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import XCTest -@testable import ClipBored - -final class LinkPreviewWindowControllerTests: XCTestCase { - func testReusedPreviewIgnoresStaleObservedTitleUntilNewNavigationStarts() throws { - let controller = LinkPreviewWindowController() - let firstURL = try XCTUnwrap(URL(string: "https://example.com/old")) - let secondURL = try XCTUnwrap(URL(string: "https://example.com/new")) - - controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title")) - controller.debugAllowObservedPageTitles() - controller.debugApplyObservedPageTitle("Old loaded title") - - XCTAssertEqual(controller.debugTitleText, "Old loaded title") - - controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title")) - controller.debugApplyObservedPageTitle("Old loaded title") - - XCTAssertEqual(controller.debugTitleText, "New request title") - XCTAssertEqual(controller.debugAddressText, "https://example.com/new") - XCTAssertEqual(controller.debugStatusText, "Loading") - - controller.debugAllowObservedPageTitles() - controller.debugApplyObservedPageTitle("New loaded title") - - XCTAssertEqual(controller.debugTitleText, "New loaded title") - } - - func testCancelledNavigationDoesNotShowFalseLoadFailure() throws { - let controller = LinkPreviewWindowController() - let url = try XCTUnwrap(URL(string: "https://example.com/new")) - - controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "New request title")) - controller.debugApplyNavigationFailure(URLError(.cancelled)) - - XCTAssertEqual(controller.debugStatusText, "Loading") - - controller.debugApplyNavigationFailure(URLError(.timedOut)) - - XCTAssertEqual(controller.debugStatusText, "Could not load") - } - - func testToolbarTooltipsTrackFullVisibleText() throws { - let controller = LinkPreviewWindowController() - let url = try XCTUnwrap(URL(string: "https://example.com/articles/a-very-long-release-note-title?ref=clipbored")) - - controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "Release note with a long title")) - - XCTAssertEqual(controller.debugTitleTooltip, "Release note with a long title") - XCTAssertEqual(controller.debugAddressTooltip, url.absoluteString) - XCTAssertEqual(controller.debugStatusTooltip, "Loading") - - controller.debugAllowObservedPageTitles() - controller.debugApplyObservedPageTitle("Loaded page title that may truncate in the toolbar") - - XCTAssertEqual(controller.debugTitleTooltip, "Loaded page title that may truncate in the toolbar") - - controller.debugApplyNavigationFailure(URLError(.timedOut)) - - XCTAssertEqual(controller.debugStatusTooltip, "Could not load") - } - - func testOpenInBrowserUsesDisplayedPageURLAndResetsForReusedPreview() throws { - var openedURLs: [URL] = [] - let controller = LinkPreviewWindowController { openedURLs.append($0) } - let firstURL = try XCTUnwrap(URL(string: "https://example.com/old")) - let navigatedURL = try XCTUnwrap(URL(string: "https://example.com/old/details")) - let secondURL = try XCTUnwrap(URL(string: "https://example.com/new")) - - controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title")) - controller.debugSetDisplayedPageURL(navigatedURL) - controller.debugOpenInBrowser() - - XCTAssertEqual(openedURLs, [navigatedURL]) - - controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title")) - controller.debugOpenInBrowser() - - XCTAssertEqual(openedURLs, [navigatedURL, secondURL]) - } -} diff --git a/tests/clipboredtests/OnboardingWindowControllerTests.swift b/tests/clipboredtests/OnboardingWindowControllerTests.swift index fb8487c..c5005d3 100644 --- a/tests/clipboredtests/OnboardingWindowControllerTests.swift +++ b/tests/clipboredtests/OnboardingWindowControllerTests.swift @@ -51,25 +51,4 @@ final class OnboardingWindowControllerTests: XCTestCase { XCTAssertTrue(presentation.showDockIcon) } - func testDisplayedEntryPointSelectionIsNormalizedOnRefresh() { - let settings = makeSettings() - settings.showMenuBarIcon = false - settings.showDockIcon = false - - let controller = OnboardingWindowController( - settings: settings, - onOpenAccessibility: {}, - onFinish: {} - ) - - XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled) - XCTAssertFalse(controller.debugShowDockIconIsEnabled) - } - - private func makeSettings() -> SettingsModel { - let suiteName = "com.clipbored.onboarding.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return SettingsModel(defaults: defaults) - } } diff --git a/tests/clipboredtests/SensitiveContentDetectorTests.swift b/tests/clipboredtests/SensitiveContentDetectorTests.swift index a35e325..17d15c5 100644 --- a/tests/clipboredtests/SensitiveContentDetectorTests.swift +++ b/tests/clipboredtests/SensitiveContentDetectorTests.swift @@ -43,6 +43,7 @@ final class SensitiveContentDetectorTests: XCTestCase { func testDetectsCreditCardWithLuhnCheck() { XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard) + XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard) XCTAssertNil(SensitiveContentDetector.detect("4242424242424241")) } diff --git a/tests/clipboredtests/SettingsPresentationTests.swift b/tests/clipboredtests/SettingsPresentationTests.swift new file mode 100644 index 0000000..407fa99 --- /dev/null +++ b/tests/clipboredtests/SettingsPresentationTests.swift @@ -0,0 +1,117 @@ +import AppKit +import XCTest +@testable import ClipBored + +final class SettingsPresentationTests: XCTestCase { + func testPasteAndDataStatusColors() { + let pasteCases: [(String, NSColor)] = [ + ("", .secondaryLabelColor), + ("Pasted", .systemGreen), + ("Copied. Grant Accessibility access to paste automatically.", .systemOrange), + ("Could not write item to clipboard.", .systemRed) + ] + for (message, color) in pasteCases { + XCTAssertEqual(SettingsWindowController.pasteStatusPresentation(storedStatus: message).textColor, color) + } + + let dataCases: [(String, NSColor)] = [ + ("", .secondaryLabelColor), + ("Exported 3 clips.", .systemGreen), + ("Imported 3 clips. Skipped 1 clip.", .systemOrange), + ("The archive couldn't be opened.", .systemRed) + ] + for (message, color) in dataCases { + XCTAssertEqual(SettingsWindowController.dataStatusPresentation(storedStatus: message).textColor, color) + } + } + + func testCaptureStatusColors() { + let cases: [(String, NSColor)] = [ + ("", .secondaryLabelColor), + ("Captured text from Safari.", .systemGreen), + ("Skipped: Audio items are ignored.", .systemOrange), + ("At least one content type must stay enabled.", .systemOrange), + ("Error: Clipboard read failed.", .systemRed) + ] + for (message, color) in cases { + XCTAssertEqual(SettingsWindowController.captureStatusPresentation(storedStatus: message).textColor, color) + } + } + + func testShortcutPermissionAndLifecycleStatusColors() { + XCTAssertEqual( + SettingsWindowController.shortcutStatusPresentation(storedStatus: "").textColor, + .systemGreen + ) + XCTAssertEqual( + SettingsWindowController.shortcutStatusPresentation(storedStatus: "Unsupported shortcut").textColor, + .systemRed + ) + XCTAssertEqual( + SettingsWindowController.accessibilityPermissionStatusPresentation( + storedStatus: "", + isTrusted: true + ).textColor, + .systemGreen + ) + XCTAssertEqual( + SettingsWindowController.accessibilityPermissionStatusPresentation( + storedStatus: "Permission not granted", + isTrusted: true + ).textColor, + .systemOrange + ) + XCTAssertEqual( + SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "Service unavailable").textColor, + .systemRed + ) + } + + func testCloudSyncStatusPrecedence() { + let ready = ClipboardCloudSyncStatus( + isAvailable: true, + archiveURL: nil, + lastModifiedAt: nil, + message: "iCloud is ready." + ) + let unavailable = ClipboardCloudSyncStatus( + isAvailable: false, + archiveURL: nil, + lastModifiedAt: nil, + message: "iCloud is unavailable." + ) + + XCTAssertEqual( + SettingsWindowController.cloudSyncStatusPresentation( + storedStatus: "", + isSyncEnabled: false, + cloudStatus: ready + ).message, + "iCloud Sync is off." + ) + XCTAssertEqual( + SettingsWindowController.cloudSyncStatusPresentation( + storedStatus: "", + isSyncEnabled: true, + cloudStatus: unavailable + ).textColor, + .systemOrange + ) + XCTAssertEqual( + SettingsWindowController.cloudSyncStatusPresentation( + storedStatus: "Synced 3 clips.", + isSyncEnabled: true, + cloudStatus: ready + ).textColor, + .systemGreen + ) + XCTAssertEqual( + SettingsWindowController.cloudSyncStatusPresentation( + storedStatus: "iCloud Sync failed.", + isSyncEnabled: true, + cloudStatus: ready + ).textColor, + .systemRed + ) + } +} diff --git a/tests/clipboredtests/SettingsWindowControllerTests.swift b/tests/clipboredtests/SettingsWindowControllerTests.swift deleted file mode 100644 index 040e63d..0000000 --- a/tests/clipboredtests/SettingsWindowControllerTests.swift +++ /dev/null @@ -1,1121 +0,0 @@ -import AppKit -import XCTest -@testable import ClipBored - -final class SettingsWindowControllerTests: XCTestCase { - private var tempURLs: [URL] = [] - private var defaultsSuites: [String] = [] - - override func tearDown() { - tempURLs.forEach { try? FileManager.default.removeItem(at: $0) } - for suite in defaultsSuites { - UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) - } - tempURLs.removeAll() - defaultsSuites.removeAll() - super.tearDown() - } - - func testSettingsWindowIsResizableWithPracticalMinimumSize() { - let (controller, _) = makeController() - - XCTAssertTrue(controller.debugWindowStyleMask.contains(.resizable)) - XCTAssertEqual(controller.debugWindowMinSize.width, 560, accuracy: 0.5) - XCTAssertEqual(controller.debugWindowMinSize.height, 440, accuracy: 0.5) - XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.width, 600) - XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.height, 500) - } - - func testSettingsTabsUsePinnedScrollableDocuments() { - let (controller, _) = makeController() - let metrics = controller.debugSettingsTabLayoutMetrics - - XCTAssertEqual(metrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]) - for metric in metrics { - XCTAssertGreaterThan(metric.viewport.width, 0, metric.label) - XCTAssertGreaterThan(metric.viewport.height, 0, metric.label) - XCTAssertEqual(metric.document.width, metric.viewport.width, accuracy: 1, metric.label) - XCTAssertGreaterThan(metric.document.height, 0, metric.label) - XCTAssertFalse(metric.hasHorizontalScroller, metric.label) - } - } - - func testSettingsTabsUseUnpaddedLabelsWithCustomSelector() { - let (controller, _) = makeController() - - XCTAssertEqual(controller.debugSettingsTabLayoutMetrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]) - XCTAssertEqual(controller.debugRawSettingsTabLabels.last?.clipboardTrimmed, "Data") - XCTAssertEqual(controller.debugRawSettingsTabLabels.last, "Data") - } - - func testSettingsTabsKeepContentAnchoredToTopLeadingCorner() { - let (controller, _) = makeController() - let metrics = controller.debugSettingsTabContentPlacementMetrics - - XCTAssertEqual(metrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]) - for metric in metrics { - XCTAssertGreaterThan(metric.contentBounds.width, 0, metric.label) - XCTAssertGreaterThan(metric.contentBounds.height, 0, metric.label) - XCTAssertLessThanOrEqual(metric.contentBounds.minX, 40, metric.label) - XCTAssertLessThanOrEqual(metric.contentBounds.minY, 40, metric.label) - XCTAssertLessThanOrEqual(metric.contentBounds.maxX, metric.document.width + 1, metric.label) - } - } - - func testSettingsTabsAvoidHorizontalOverflowAtMinimumWindowSize() { - let (controller, _) = makeController() - controller.debugSetWindowContentSize(NSSize(width: 560, height: 440)) - - let metrics = controller.debugSettingsTabLayoutAuditMetrics - - XCTAssertEqual(metrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]) - for metric in metrics { - XCTAssertEqual(metric.overflowingViewCount, 0, metric.label) - XCTAssertEqual(metric.zeroSizedControlCount, 0, metric.label) - } - } - - func testSettingsGeneralTabUsesCompactVerticalLayout() { - let (controller, _) = makeController() - let metrics = controller.debugSettingsTabContentPlacementMetrics - let general = try! XCTUnwrap(metrics.first { $0.label == "General" }) - - XCTAssertLessThan(general.contentBounds.maxY, 380) - } - - func testShortcutModifiersExposeHumanAccessibilityNames() { - let (controller, _) = makeController() - - XCTAssertEqual( - controller.debugOpenShortcutModifierAccessibilityLabels, - ["Command modifier", "Option modifier", "Control modifier", "Shift modifier"] - ) - XCTAssertEqual( - controller.debugOpenShortcutModifierAccessibilityHelps, - [ - "Include or remove the Command key from this shortcut.", - "Include or remove the Option key from this shortcut.", - "Include or remove the Control key from this shortcut.", - "Include or remove the Shift key from this shortcut." - ] - ) - } - - func testWritesSettingsVisualSnapshotWhenRequested() throws { - guard ProcessInfo.processInfo.environment["CLIPBORED_WRITE_SETTINGS_SNAPSHOT"] == "1" else { - throw XCTSkip("Set CLIPBORED_WRITE_SETTINGS_SNAPSHOT=1 to write a Settings snapshot.") - } - - let (controller, _) = makeController() - controller.debugSelectSettingsTab(at: 5) - controller.debugPrepareWindowForSnapshot() - drainMainQueue() - let view = try XCTUnwrap(controller.debugSettingsContentView) - view.layoutSubtreeIfNeeded() - view.displayIfNeeded() - - let output = URL( - fileURLWithPath: ProcessInfo.processInfo.environment["CLIPBORED_SETTINGS_SNAPSHOT_PATH"] - ?? "build/visual-snapshots/settings-data.png" - ) - try FileManager.default.createDirectory( - at: output.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) - rep.size = view.bounds.size - view.cacheDisplay(in: view.bounds, to: rep) - let image = NSImage(size: view.bounds.size) - image.addRepresentation(rep) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation))) - let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - try png.write(to: output) - } - - func testCloudSyncStatusIsCachedAcrossUnrelatedSettingsRefreshes() { - let cloudSyncService = CloudSyncProbe( - status: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - let (controller, settings) = makeController(cloudSyncService: cloudSyncService) - - XCTAssertEqual(controller.debugCloudSyncStatusText, "iCloud Sync is off.") - XCTAssertEqual(controller.debugCloudSyncActionButtonsAreEnabled, [false, false, false]) - XCTAssertEqual(cloudSyncService.statusCallCount, 0) - - settings.iCloudSyncEnabled = true - drainMainQueue() - - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud ready") - XCTAssertEqual(controller.debugCloudSyncActionButtonsAreEnabled, [true, true, true]) - - settings.maxHistoryItems += 25 - settings.setPasteStatus(message: "Pasted latest clip") - drainMainQueue() - - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud ready") - - settings.setCloudSyncStatus(message: "Synced 3 clips and 1 attachment to iCloud.") - drainMainQueue() - - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Synced 3 clips and 1 attachment to iCloud.") - - settings.setCloudSyncStatus(message: "") - drainMainQueue() - - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud ready") - } - - func testTurningCloudSyncOffClearsStaleActionStatus() { - let cloudSyncService = CloudSyncProbe( - status: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - let (controller, settings) = makeController(cloudSyncService: cloudSyncService) - let initialFullRefreshCount = controller.debugFullRefreshCount - - controller.debugSetICloudSyncEnabled(true) - drainMainQueue() - - XCTAssertTrue(settings.iCloudSyncEnabled) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud ready") - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - - settings.setCloudSyncStatus(message: "Synced 3 clips and 1 attachment to iCloud.") - drainMainQueue() - - XCTAssertEqual(controller.debugCloudSyncStatusText, "Synced 3 clips and 1 attachment to iCloud.") - - controller.debugSetICloudSyncEnabled(false) - drainMainQueue() - - XCTAssertFalse(settings.iCloudSyncEnabled) - XCTAssertEqual(settings.cloudSyncStatusMessage, "") - XCTAssertEqual(controller.debugCloudSyncStatusText, "iCloud Sync is off.") - XCTAssertEqual(controller.debugCloudSyncActionButtonsAreEnabled, [false, false, false]) - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testCloudSyncActionsStayDisabledWhenSyncIsUnavailable() { - let cloudSyncService = CloudSyncProbe( - status: ClipboardCloudSyncStatus( - isAvailable: false, - archiveURL: nil, - lastModifiedAt: nil, - message: "Probe iCloud unavailable" - ) - ) - let (controller, settings) = makeController(cloudSyncService: cloudSyncService) - let initialFullRefreshCount = controller.debugFullRefreshCount - - XCTAssertEqual(controller.debugCloudSyncActionButtonsAreEnabled, [false, false, false]) - - settings.iCloudSyncEnabled = true - drainMainQueue() - - XCTAssertTrue(settings.iCloudSyncEnabled) - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud unavailable") - XCTAssertEqual(controller.debugCloudSyncActionButtonsAreEnabled, [false, false, false]) - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testCloudSyncStatusPresentationUsesErrorColorForFailedActionMessages() { - let failed = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "iCloud Sync failed: No ClipBored iCloud archive has been created yet.", - isSyncEnabled: true, - cloudStatus: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - - XCTAssertEqual(failed.message, "iCloud Sync failed: No ClipBored iCloud archive has been created yet.") - XCTAssertEqual(failed.textColor, .systemRed) - - let unavailable = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "", - isSyncEnabled: true, - cloudStatus: ClipboardCloudSyncStatus( - isAvailable: false, - archiveURL: nil, - lastModifiedAt: nil, - message: "Probe iCloud unavailable" - ) - ) - - XCTAssertEqual(unavailable.message, "Probe iCloud unavailable") - XCTAssertEqual(unavailable.textColor, .systemOrange) - - let synced = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "Synced 3 clips and 1 attachment to iCloud.", - isSyncEnabled: true, - cloudStatus: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - - XCTAssertEqual(synced.message, "Synced 3 clips and 1 attachment to iCloud.") - XCTAssertEqual(synced.textColor, .systemGreen) - - let opened = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "Opened iCloud sync location.", - isSyncEnabled: true, - cloudStatus: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - - XCTAssertEqual(opened.message, "Opened iCloud sync location.") - XCTAssertEqual(opened.textColor, .systemGreen) - - let restoredWithSkips = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "Restored 3 clips and 1 attachment from iCloud. Skipped 1 clip and 0 attachments.", - isSyncEnabled: true, - cloudStatus: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - - XCTAssertEqual( - restoredWithSkips.message, - "Restored 3 clips and 1 attachment from iCloud. Skipped 1 clip and 0 attachments." - ) - XCTAssertEqual(restoredWithSkips.textColor, .systemOrange) - - let off = SettingsWindowController.cloudSyncStatusPresentation( - storedStatus: "", - isSyncEnabled: false, - cloudStatus: nil - ) - - XCTAssertEqual(off.message, "iCloud Sync is off.") - XCTAssertEqual(off.textColor, .secondaryLabelColor) - } - - func testExternalCloudSyncDisableShowsOffStateInsteadOfStaleStatus() { - let cloudSyncService = CloudSyncProbe( - status: ClipboardCloudSyncStatus( - isAvailable: true, - archiveURL: makeTempDirectory().appendingPathComponent("ClipBored.clipboredarchive"), - lastModifiedAt: nil, - message: "Probe iCloud ready" - ) - ) - let (controller, settings) = makeController(cloudSyncService: cloudSyncService) - let initialFullRefreshCount = controller.debugFullRefreshCount - - settings.iCloudSyncEnabled = true - drainMainQueue() - - XCTAssertEqual(controller.debugCloudSyncStatusText, "Probe iCloud ready") - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - - settings.setCloudSyncStatus(message: "Synced 3 clips and 1 attachment to iCloud.") - drainMainQueue() - - XCTAssertEqual(controller.debugCloudSyncStatusText, "Synced 3 clips and 1 attachment to iCloud.") - - settings.iCloudSyncEnabled = false - drainMainQueue() - - XCTAssertEqual(settings.cloudSyncStatusMessage, "") - XCTAssertEqual(controller.debugCloudSyncStatusText, "iCloud Sync is off.") - XCTAssertEqual(cloudSyncService.statusCallCount, 1) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testIgnoredAppsEditorPreservesDraftTextWhileFocused() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - settings.ignoredApps = ["Safari", "Xcode"] - drainMainQueue() - - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari, Xcode") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertGreaterThan(controller.debugIgnoredAppsRefreshCount, 0) - - XCTAssertTrue(controller.debugFocusIgnoredAppsEditor()) - XCTAssertTrue(controller.debugIgnoredAppsEditorIsFocused) - - let focusedFullRefreshCount = controller.debugFullRefreshCount - let focusedIgnoredAppsRefreshCount = controller.debugIgnoredAppsRefreshCount - controller.debugSetIgnoredAppsText("Safari,\nXcode, Terminal, ") - drainMainQueue() - - XCTAssertEqual(settings.ignoredApps, ["Safari", "Xcode"]) - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari,\nXcode, Terminal, ") - XCTAssertEqual(controller.debugFullRefreshCount, focusedFullRefreshCount) - XCTAssertEqual(controller.debugIgnoredAppsRefreshCount, focusedIgnoredAppsRefreshCount) - - settings.maxHistoryItems += 25 - settings.setPasteStatus(message: "Pasted latest clip") - drainMainQueue() - - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari,\nXcode, Terminal, ") - - controller.debugEndIgnoredAppsEditing() - drainMainQueue() - - XCTAssertEqual(settings.ignoredApps, ["Safari", "Xcode", "Terminal"]) - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari, Xcode, Terminal") - } - - func testFocusedIgnoredAppsEditorWithoutDraftDoesNotOverwriteExternalChanges() { - let (controller, settings) = makeController() - settings.ignoredApps = ["Safari"] - drainMainQueue() - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari") - - XCTAssertTrue(controller.debugFocusIgnoredAppsEditor()) - - settings.ignoredApps = ["Xcode", "Terminal"] - drainMainQueue() - - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari") - XCTAssertEqual(settings.ignoredApps, ["Xcode", "Terminal"]) - - controller.debugEndIgnoredAppsEditing() - drainMainQueue() - - XCTAssertEqual(controller.debugIgnoredAppsText, "Xcode, Terminal") - XCTAssertEqual(settings.ignoredApps, ["Xcode", "Terminal"]) - } - - func testClosingSettingsWindowCommitsFocusedIgnoredAppsDraft() { - let (controller, settings) = makeController() - settings.ignoredApps = ["Safari"] - drainMainQueue() - - XCTAssertTrue(controller.debugFocusIgnoredAppsEditor()) - controller.debugSetIgnoredAppsText("Safari, Xcode, Terminal") - drainMainQueue() - - XCTAssertEqual(settings.ignoredApps, ["Safari"]) - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari, Xcode, Terminal") - - controller.debugCloseWindow() - drainMainQueue() - - XCTAssertEqual(settings.ignoredApps, ["Safari", "Xcode", "Terminal"]) - XCTAssertEqual(controller.debugIgnoredAppsText, "Safari, Xcode, Terminal") - } - - func testNarrowSettingsChangesAvoidFullWindowRefresh() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - settings.maxHistoryItems = 75 - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertEqual(controller.debugHistoryText, "History length: 75") - - settings.imageCacheMaxBytes = 128 * 1024 * 1024 - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertEqual(controller.debugCacheStatusText, "Current cache cap: 128 MB") - - settings.setPasteStatus(message: "Pasted latest clip") - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertEqual(controller.debugPasteStatusText, "Pasted latest clip") - } - - func testLimitControlsRefreshToClampedStoredValues() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - let minCacheMegabytes = Int(AppConfiguration.minCacheMaxBytes / 1024 / 1024) - let maxCacheMegabytes = Int(AppConfiguration.maxCacheMaxBytes / 1024 / 1024) - - controller.debugSetHistoryStepperValue(AppConfiguration.minHistoryLength - 25) - drainMainQueue() - - XCTAssertEqual(settings.maxHistoryItems, AppConfiguration.minHistoryLength) - XCTAssertEqual(controller.debugHistoryText, "History length: \(AppConfiguration.minHistoryLength)") - XCTAssertEqual(controller.debugHistoryStepperValue, AppConfiguration.minHistoryLength) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetHistoryStepperValue(AppConfiguration.maxHistoryLength + 25) - drainMainQueue() - - XCTAssertEqual(settings.maxHistoryItems, AppConfiguration.maxHistoryLength) - XCTAssertEqual(controller.debugHistoryText, "History length: \(AppConfiguration.maxHistoryLength)") - XCTAssertEqual(controller.debugHistoryStepperValue, AppConfiguration.maxHistoryLength) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetCacheSliderMegabytes(minCacheMegabytes - 2) - drainMainQueue() - - XCTAssertEqual(settings.imageCacheMaxBytes, AppConfiguration.minCacheMaxBytes) - XCTAssertEqual(controller.debugCacheStatusText, "Current cache cap: \(minCacheMegabytes) MB") - XCTAssertEqual(controller.debugCacheSliderMegabytes, minCacheMegabytes) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetCacheSliderMegabytes(maxCacheMegabytes + 128) - drainMainQueue() - - XCTAssertEqual(settings.imageCacheMaxBytes, AppConfiguration.maxCacheMaxBytes) - XCTAssertEqual(controller.debugCacheStatusText, "Current cache cap: \(maxCacheMegabytes) MB") - XCTAssertEqual(controller.debugCacheSliderMegabytes, maxCacheMegabytes) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testStatusLabelsWrapLongMessagesInsteadOfTruncating() { - let (controller, settings) = makeController() - let longMessage = "Copied 12 selected clips as Text. Grant Accessibility access in System Settings to paste automatically into the previous application." - - settings.setPasteStatus(message: longMessage) - drainMainQueue() - - XCTAssertEqual(controller.debugPasteStatusText, longMessage) - XCTAssertTrue(controller.debugStatusLabelsAllowWrapping) - } - - func testPasteStatusPresentationUsesSuccessWarningAndErrorColors() { - let idle = SettingsWindowController.pasteStatusPresentation(storedStatus: "") - - XCTAssertEqual(idle.message, "No paste action yet.") - XCTAssertEqual(idle.textColor, .secondaryLabelColor) - - let pasted = SettingsWindowController.pasteStatusPresentation(storedStatus: "Pasted") - - XCTAssertEqual(pasted.message, "Pasted") - XCTAssertEqual(pasted.textColor, .systemGreen) - - let copiedNeedsPermission = SettingsWindowController.pasteStatusPresentation( - storedStatus: "Copied. Grant Accessibility access to paste automatically." - ) - - XCTAssertEqual(copiedNeedsPermission.message, "Copied. Grant Accessibility access to paste automatically.") - XCTAssertEqual(copiedNeedsPermission.textColor, .systemOrange) - - let failed = SettingsWindowController.pasteStatusPresentation( - storedStatus: "Could not write item to clipboard." - ) - - XCTAssertEqual(failed.message, "Could not write item to clipboard.") - XCTAssertEqual(failed.textColor, .systemRed) - } - - func testDataStatusPresentationUsesSuccessWarningAndErrorColors() { - let idle = SettingsWindowController.dataStatusPresentation(storedStatus: "") - - XCTAssertEqual(idle.message, "") - XCTAssertEqual(idle.textColor, .secondaryLabelColor) - - let exported = SettingsWindowController.dataStatusPresentation( - storedStatus: "Exported 3 clips and 1 attachments." - ) - - XCTAssertEqual(exported.message, "Exported 3 clips and 1 attachments.") - XCTAssertEqual(exported.textColor, .systemGreen) - - let cleared = SettingsWindowController.dataStatusPresentation( - storedStatus: "Cleared thumbnail cache." - ) - - XCTAssertEqual(cleared.message, "Cleared thumbnail cache.") - XCTAssertEqual(cleared.textColor, .systemGreen) - - let partialImport = SettingsWindowController.dataStatusPresentation( - storedStatus: "Imported 3 clips and 1 attachments. Skipped 1 clips and 0 attachments." - ) - - XCTAssertEqual( - partialImport.message, - "Imported 3 clips and 1 attachments. Skipped 1 clips and 0 attachments." - ) - XCTAssertEqual(partialImport.textColor, .systemOrange) - - let failed = SettingsWindowController.dataStatusPresentation( - storedStatus: "The archive couldn't be opened." - ) - - XCTAssertEqual(failed.message, "The archive couldn't be opened.") - XCTAssertEqual(failed.textColor, .systemRed) - } - - func testDestructiveDataActionsShowSuccessStatus() { - let fixture = makeControllerFixture() - fixture.store.upsert(makeItem("saved clip", displayText: "Saved Clip", created: Date())) - - XCTAssertEqual(fixture.store.items.count, 1) - XCTAssertEqual(fixture.controller.debugDataStatusText, "") - XCTAssertEqual(fixture.controller.debugDataStatusSectionTitle, "Data") - - fixture.controller.debugSetDestructiveActionConfirmation(true) - fixture.controller.debugClearClipboardHistory() - - XCTAssertTrue(fixture.store.items.isEmpty) - XCTAssertEqual(fixture.controller.debugDataStatusText, "Cleared clipboard history.") - XCTAssertEqual(fixture.controller.debugDataStatusColor, .systemGreen) - - fixture.controller.debugClearThumbnailCache() - - XCTAssertEqual(fixture.controller.debugDataStatusText, "Cleared thumbnail cache.") - XCTAssertEqual(fixture.controller.debugDataStatusColor, .systemGreen) - } - - func testCaptureStatusPresentationUsesReadyWarningAndErrorColors() { - let idle = SettingsWindowController.captureStatusPresentation(storedStatus: "") - - XCTAssertEqual(idle.message, "Capture status will appear after the app sees clipboard activity.") - XCTAssertEqual(idle.textColor, .secondaryLabelColor) - - let captured = SettingsWindowController.captureStatusPresentation( - storedStatus: "Captured text from Safari." - ) - - XCTAssertEqual(captured.message, "Captured text from Safari.") - XCTAssertEqual(captured.textColor, .systemGreen) - - let skipped = SettingsWindowController.captureStatusPresentation( - storedStatus: "Skipped: Audio items are ignored in capture settings." - ) - - XCTAssertEqual(skipped.message, "Skipped: Audio items are ignored in capture settings.") - XCTAssertEqual(skipped.textColor, .systemOrange) - - let validationWarning = SettingsWindowController.captureStatusPresentation( - storedStatus: "At least one content type must stay enabled." - ) - - XCTAssertEqual(validationWarning.message, "At least one content type must stay enabled.") - XCTAssertEqual(validationWarning.textColor, .systemOrange) - - let error = SettingsWindowController.captureStatusPresentation( - storedStatus: "Error: Clipboard read failed." - ) - - XCTAssertEqual(error.message, "Error: Clipboard read failed.") - XCTAssertEqual(error.textColor, .systemRed) - } - - func testAccessibilityPermissionRefreshAvoidsFullWindowRefresh() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - settings.setAccessibilityPermissionStatus(message: "Accessibility permission not granted.") - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertEqual(controller.debugAccessibilityStatusText, "Accessibility permission not granted.") - - controller.debugRefreshAccessibilityPermissionStatus() - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertFalse(controller.debugAccessibilityStatusText.isEmpty) - } - - func testStoredAccessibilityWarningUsesWarningColorEvenWhenPermissionIsGranted() { - let warning = SettingsWindowController.accessibilityPermissionStatusPresentation( - storedStatus: "Accessibility permission not granted.", - isTrusted: true - ) - - XCTAssertEqual(warning.message, "Accessibility permission not granted.") - XCTAssertEqual(warning.textColor, .systemOrange) - - let granted = SettingsWindowController.accessibilityPermissionStatusPresentation( - storedStatus: "", - isTrusted: true - ) - - XCTAssertEqual(granted.message, "Granted") - XCTAssertEqual(granted.textColor, .systemGreen) - } - - func testCommonControlSettingsAvoidFullWindowRefresh() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - settings.defaultSortMode = .links - settings.includeImageTextInSearch = true - settings.pruneDuplicates = false - settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue] - settings.keepFirstImage = false - settings.excludeSensitive = true - settings.clearHistoryOnQuit = true - drainMainQueue() - - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - XCTAssertEqual(controller.debugDefaultSortTitle, ClipboardSortMode.links.title) - XCTAssertTrue(controller.debugIncludeImageTextIsEnabled) - XCTAssertFalse(controller.debugPruneDuplicatesIsEnabled) - XCTAssertFalse(controller.debugAllowedKindIsEnabled(.image)) - XCTAssertTrue(controller.debugAllowedKindIsEnabled(.text)) - XCTAssertFalse(controller.debugKeepFirstImageIsEnabled) - XCTAssertTrue(controller.debugExcludeSensitiveIsEnabled) - XCTAssertTrue(controller.debugClearHistoryOnQuitIsEnabled) - } - - func testAllowedContentTypesKeepAtLeastOneKindEnabled() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - let kindsToDisable: [ClipboardItemKind] = [ - .code, - .url, - .image, - .color, - .audio, - .video, - .richText, - .pdf, - .file - ] - - settings.setCaptureStatus(message: "Captured text from Safari.") - drainMainQueue() - XCTAssertEqual(controller.debugCaptureStatusColor, .systemGreen) - - for kind in kindsToDisable { - controller.debugSetAllowedKindEnabled(kind, false) - drainMainQueue() - XCTAssertFalse(controller.debugAllowedKindIsEnabled(kind)) - } - - XCTAssertTrue(controller.debugAllowedKindIsEnabled(.text)) - XCTAssertFalse(settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) - - controller.debugSetAllowedKindEnabled(.text, false) - drainMainQueue() - - XCTAssertTrue(controller.debugAllowedKindIsEnabled(.text)) - XCTAssertFalse(settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) - XCTAssertEqual(controller.debugCaptureStatusText, "At least one content type must stay enabled.") - XCTAssertEqual(controller.debugCaptureStatusColor, .systemOrange) - - XCTAssertEqual(settings.captureStatusMessage, "At least one content type must stay enabled.") - settings.setPasteStatus(message: "Pasted latest clip") - drainMainQueue() - - XCTAssertEqual(controller.debugCaptureStatusText, "At least one content type must stay enabled.") - XCTAssertEqual(controller.debugCaptureStatusColor, .systemOrange) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetAllowedKindEnabled(.image, true) - drainMainQueue() - - XCTAssertTrue(controller.debugAllowedKindIsEnabled(.image)) - XCTAssertEqual(settings.captureStatusMessage, "Allowed content types updated.") - XCTAssertEqual(controller.debugCaptureStatusText, "Allowed content types updated.") - XCTAssertEqual(controller.debugCaptureStatusColor, .systemGreen) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testExternalAllowedContentTypeChangesKeepAtLeastOneKindEnabled() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - let allVisibleKindRawValues = Self.visibleItemKinds.map(\.rawValue) - - settings.ignoredItemKindsRaw = allVisibleKindRawValues - drainMainQueue() - - XCTAssertTrue(controller.debugAllowedKindIsEnabled(.text)) - XCTAssertFalse(settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) - for kind in Self.visibleItemKinds where kind != .text { - XCTAssertFalse(controller.debugAllowedKindIsEnabled(kind)) - } - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testShortcutEditsAvoidFullWindowRefreshAndRecoverEmptyKey() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - controller.debugCommitOpenShortcutKeyText("k") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "k") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "K") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugCommitOpenShortcutKeyText("space") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "k") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "K") - XCTAssertEqual(controller.debugShortcutStatusText, "Unsupported shortcut: ⌘⌥SPACE") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugCommitOpenShortcutKeyText("⌘") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "k") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "K") - XCTAssertEqual(controller.debugShortcutStatusText, "Unsupported shortcut: ⌘") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugCommitOpenShortcutKeyText("") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "k") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "K") - XCTAssertEqual(controller.debugShortcutStatusText, "Registered") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testShortcutStatusPresentationUsesPositiveAndErrorColors() { - let registered = SettingsWindowController.shortcutStatusPresentation(storedStatus: "") - - XCTAssertEqual(registered.message, "Registered") - XCTAssertEqual(registered.textColor, .systemGreen) - - let unsupported = SettingsWindowController.shortcutStatusPresentation( - storedStatus: "Unsupported shortcut: ⇧V" - ) - - XCTAssertEqual(unsupported.message, "Unsupported shortcut: ⇧V") - XCTAssertEqual(unsupported.textColor, .systemRed) - - let conflict = SettingsWindowController.shortcutStatusPresentation( - storedStatus: "Shortcut is already in use: ⌘⇧C" - ) - - XCTAssertEqual(conflict.message, "Shortcut is already in use: ⌘⇧C") - XCTAssertEqual(conflict.textColor, .systemRed) - } - - func testShortcutDraftSurvivesExternalShortcutRefreshWhileEditing() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - XCTAssertTrue(controller.debugBeginOpenShortcutKeyEditing()) - controller.debugSetOpenShortcutKeyDraft("z") - - settings.openShortcut = ShortcutBinding( - key: "j", - modifierFlags: NSEvent.ModifierFlags.command.rawValue - ) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "j") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "z") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugEndOpenShortcutKeyEditing() - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "z") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "Z") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testClosingSettingsWindowCommitsShortcutDraft() { - let (controller, settings) = makeController() - let initialShortcut = settings.openShortcut - - XCTAssertTrue(controller.debugBeginOpenShortcutKeyEditing()) - controller.debugSetOpenShortcutKeyDraft("b") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, initialShortcut) - XCTAssertEqual(controller.debugOpenShortcutKeyText, "b") - - controller.debugCloseWindow() - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "b") - XCTAssertEqual(settings.openShortcut.modifierFlags, initialShortcut.modifierFlags) - XCTAssertEqual(controller.debugOpenShortcutKeyText, "B") - } - - func testShortcutModifierEditsRejectUnsupportedBindingsWithoutSaving() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - let initialShortcut = settings.openShortcut - - controller.debugSetOpenShortcutModifiers(command: false, option: false, control: false, shift: false) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, initialShortcut) - XCTAssertEqual(controller.debugOpenShortcutKeyText, initialShortcut.key.uppercased()) - XCTAssertEqual(controller.debugShortcutStatusText, "Unsupported shortcut: V") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetOpenShortcutModifiers(command: false, option: false, control: false, shift: true) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, initialShortcut) - XCTAssertEqual(controller.debugShortcutStatusText, "Unsupported shortcut: ⇧V") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetOpenShortcutModifiers(command: false, option: false, control: true, shift: false) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, initialShortcut.key) - XCTAssertEqual(settings.openShortcut.modifierFlags, NSEvent.ModifierFlags.control.rawValue) - XCTAssertEqual(controller.debugShortcutStatusText, "Registered") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testShortcutKeyFieldExplicitModifiersReplaceExistingModifierState() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - XCTAssertEqual(settings.openShortcut.displayText, "⌘⌥V") - - controller.debugCommitOpenShortcutKeyText("⌘K") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut.key, "k") - XCTAssertEqual(settings.openShortcut.modifierFlags, NSEvent.ModifierFlags.command.rawValue) - XCTAssertEqual(settings.openShortcut.displayText, "⌘K") - XCTAssertEqual(controller.debugOpenShortcutKeyText, "K") - XCTAssertEqual(controller.debugShortcutStatusText, "Registered") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testShortcutEditsRejectConflictingBindingsWithoutSaving() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - controller.debugCommitOpenShortcutKeyText(",") - drainMainQueue() - let commandOptionComma = settings.openShortcut - - controller.debugSetOpenShortcutModifiers(command: true, option: false, control: false, shift: false) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, commandOptionComma) - XCTAssertEqual(controller.debugShortcutStatusText, "Shortcut is already in use: ⌘,") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugCommitOpenShortcutKeyText("space") - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, commandOptionComma) - XCTAssertEqual(controller.debugOpenShortcutKeyText, ",") - XCTAssertEqual(controller.debugShortcutStatusText, "Unsupported shortcut: ⌘⌥SPACE") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugCommitOpenShortcutKeyText("c") - drainMainQueue() - let commandOptionC = settings.openShortcut - - controller.debugSetOpenShortcutModifiers(command: true, option: false, control: false, shift: true) - drainMainQueue() - - XCTAssertEqual(settings.openShortcut, commandOptionC) - XCTAssertEqual(controller.debugShortcutStatusText, "Shortcut is already in use: ⌘⇧C") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testLaunchAtLoginChangesAvoidFullWindowRefresh() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - settings.launchAtLogin = true - drainMainQueue() - - XCTAssertTrue(controller.debugLaunchAtLoginIsEnabled) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - settings.setLaunchAtLoginStatus(message: "Launch agent unavailable") - drainMainQueue() - - XCTAssertEqual(controller.debugLaunchStatusText, "Launch agent unavailable") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetLaunchAtLoginEnabled(false) - drainMainQueue(iterations: 40) - - XCTAssertFalse(settings.launchAtLogin) - XCTAssertEqual(settings.launchAtLoginErrorMessage, "") - XCTAssertFalse(controller.debugLaunchAtLoginIsEnabled) - XCTAssertEqual(controller.debugLaunchStatusText, "") - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - func testLaunchAtLoginStatusPresentationUsesErrorColorForFailures() { - let idle = SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "") - - XCTAssertEqual(idle.message, "") - XCTAssertEqual(idle.textColor, .secondaryLabelColor) - - let failure = SettingsWindowController.launchAtLoginStatusPresentation( - storedStatus: "Launch-at-login failed: Service unavailable" - ) - - XCTAssertEqual(failure.message, "Launch-at-login failed: Service unavailable") - XCTAssertEqual(failure.textColor, .systemRed) - } - - func testVisibilityControlsKeepOneSurfaceEnabledWithoutFullRefresh() { - let (controller, settings) = makeController() - let initialFullRefreshCount = controller.debugFullRefreshCount - - XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled) - XCTAssertFalse(controller.debugShowDockIconIsEnabled) - - controller.debugSetShowMenuBarIconEnabled(false) - - XCTAssertFalse(settings.showMenuBarIcon) - XCTAssertTrue(settings.showDockIcon) - XCTAssertFalse(controller.debugShowMenuBarIconIsEnabled) - XCTAssertTrue(controller.debugShowDockIconIsEnabled) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - drainMainQueue() - - XCTAssertFalse(controller.debugShowMenuBarIconIsEnabled) - XCTAssertTrue(controller.debugShowDockIconIsEnabled) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - - controller.debugSetShowDockIconEnabled(false) - - XCTAssertTrue(settings.showMenuBarIcon) - XCTAssertFalse(settings.showDockIcon) - XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled) - XCTAssertFalse(controller.debugShowDockIconIsEnabled) - XCTAssertEqual(controller.debugFullRefreshCount, initialFullRefreshCount) - } - - private struct ControllerFixture { - let controller: SettingsWindowController - let settings: SettingsModel - let store: ClipboardStore - } - - private func makeControllerFixture( - cloudSyncService: ClipboardCloudSyncServicing = CloudSyncProbe() - ) -> ControllerFixture { - let suiteName = "com.clipbored.settingswindow.\(UUID().uuidString)" - defaultsSuites.append(suiteName) - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - let settings = SettingsModel(defaults: defaults) - settings.maxHistoryItems = 50 - settings.historyRetention = .forever - let baseURL = makeTempDirectory() - let encryptionService = ClipboardEncryptionService(keyProvider: { nil }) - let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: encryptionService) - let store = ClipboardStore( - settings: settings, - cacheService: cacheService, - baseURL: baseURL, - encryptionService: encryptionService - ) - let controller = SettingsWindowController( - settings: settings, - store: store, - cacheService: cacheService, - cloudSyncService: cloudSyncService - ) - return ControllerFixture(controller: controller, settings: settings, store: store) - } - - private func makeController( - cloudSyncService: ClipboardCloudSyncServicing = CloudSyncProbe() - ) -> (SettingsWindowController, SettingsModel) { - let fixture = makeControllerFixture(cloudSyncService: cloudSyncService) - return (fixture.controller, fixture.settings) - } - - private func makeItem(_ payload: String, displayText: String, created: Date) -> ClipboardItem { - ClipboardItem( - id: UUID(), - kind: .text, - displayText: displayText, - payload: payload, - payloadHash: payload, - createdAt: created, - lastUsedAt: created, - useCount: 1, - sourceApp: nil, - imagePath: nil, - thumbnailPath: nil, - isPinned: false - ) - } - - private func makeTempDirectory() -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("clipbored-settingswindow-tests", isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - tempURLs.append(directory) - return directory - } - - private func drainMainQueue(iterations: Int = 20) { - for _ in 0.. URL { - if let archiveURL = statusResponse.archiveURL { - return archiveURL - } - throw ClipboardCloudSyncError.unavailable - } - - func status() -> ClipboardCloudSyncStatus { - statusCallCount += 1 - return statusResponse - } - - func push(store: ClipboardStore) throws -> ClipboardArchiveSummary { - ClipboardArchiveSummary(itemCount: 0, sidecarCount: 0, skippedItemCount: 0, skippedSidecarCount: 0) - } - - func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary { - ClipboardArchiveSummary(itemCount: 0, sidecarCount: 0, skippedItemCount: 0, skippedSidecarCount: 0) - } -} diff --git a/tests/clipboredtests/ShortcutManagerTests.swift b/tests/clipboredtests/ShortcutManagerTests.swift index bd0c7bc..f19bf6a 100644 --- a/tests/clipboredtests/ShortcutManagerTests.swift +++ b/tests/clipboredtests/ShortcutManagerTests.swift @@ -36,13 +36,6 @@ final class ShortcutManagerTests: XCTestCase { manager.stop() } - func testGlobalRegistrationExcludesLocalSettingsShortcut() { - let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut) - - XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut]) - XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut)) - } - func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() { let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)