From d86d392c3a91e164aabe1b1deb5646e910c0b068 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Wed, 8 Jul 2026 01:13:02 -0400 Subject: [PATCH] UI fixes --- Package.swift | 1 + README.md | 48 +- docs/ARCHITECTURE.md | 17 +- docs/RELEASE.md | 6 +- docs/ROADMAP.md | 8 +- docs/SECURITY.md | 15 +- docs/SMOKE_TEST.md | 130 +- docs/thingstofix.md | 11 + scripts/build-macos-app.sh | 14 +- sources/clipbored/app/AppDelegate.swift | 343 +- .../clipbored/config/AppConfiguration.swift | 2 + .../extensions/NSImage+Helpers.swift | 36 + sources/clipbored/models/ClipboardItem.swift | 57 +- .../clipbored/models/LinkPreviewRequest.swift | 6 + sources/clipbored/models/SettingsModel.swift | 234 +- sources/clipbored/models/VideoPayload.swift | 26 +- .../services/ClipboardArchiveService.swift | 328 + .../services/ClipboardCacheService.swift | 35 +- .../services/ClipboardCloudSyncService.swift | 111 + .../services/ClipboardEncryptionService.swift | 23 +- .../services/ClipboardMonitorService.swift | 10 +- .../clipbored/services/ClipboardStore.swift | 364 +- .../services/ImageTextExtractor.swift | 18 +- .../services/PasteActionService.swift | 37 + .../clipbored/services/ShortcutManager.swift | 32 +- .../views/ClipboardPanelController.swift | 396 +- .../clipbored/views/ClipboardPanelView.swift | 5989 ++++++++++++++--- .../views/ClipboardPanelViewModel.swift | 1968 +++++- .../views/LinkPreviewWindowController.swift | 404 ++ .../views/OnboardingWindowController.swift | 367 + .../views/SettingsWindowController.swift | 1361 +++- tests/clipboredtests/AppDelegateTests.swift | 70 +- .../ClipboardCloudSyncServiceTests.swift | 144 + .../ClipboardEncryptionServiceTests.swift | 19 + .../ClipboardMonitorServiceTests.swift | 60 +- .../ClipboardPanelControllerTests.swift | 418 +- .../ClipboardPanelViewModelTests.swift | 1584 ++++- .../ClipboardPanelViewTests.swift | 2261 ++++++- .../clipboredtests/ClipboardStoreTests.swift | 356 +- .../DiagnosticsServiceTests.swift | 13 +- .../LinkPreviewWindowControllerTests.swift | 81 + .../OnboardingWindowControllerTests.swift | 75 + .../PasteActionServiceTests.swift | 128 +- tests/clipboredtests/SettingsModelTests.swift | 427 ++ .../SettingsWindowControllerTests.swift | 1064 +++ .../clipboredtests/ShortcutManagerTests.swift | 7 + 46 files changed, 17543 insertions(+), 1561 deletions(-) create mode 100644 docs/thingstofix.md create mode 100644 sources/clipbored/models/LinkPreviewRequest.swift create mode 100644 sources/clipbored/services/ClipboardArchiveService.swift create mode 100644 sources/clipbored/services/ClipboardCloudSyncService.swift create mode 100644 sources/clipbored/views/LinkPreviewWindowController.swift create mode 100644 sources/clipbored/views/OnboardingWindowController.swift create mode 100644 tests/clipboredtests/ClipboardCloudSyncServiceTests.swift create mode 100644 tests/clipboredtests/LinkPreviewWindowControllerTests.swift create mode 100644 tests/clipboredtests/OnboardingWindowControllerTests.swift create mode 100644 tests/clipboredtests/SettingsWindowControllerTests.swift diff --git a/Package.swift b/Package.swift index f06deb9..f3c4d50 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .linkedFramework("QuickLookUI"), .linkedFramework("Security"), .linkedFramework("Vision"), + .linkedFramework("WebKit"), .linkedLibrary("sqlite3") ] ), diff --git a/README.md b/README.md index 700c4ca..aa3c8a8 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,51 @@ # ClipBored -ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first responsive bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode. +ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first side panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode. The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only. ## Features - Dockless menu-bar utility by default (`LSUIElement=true`), with a Settings toggle for normal Dock presence -- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit +- First-run setup assistant for the open shortcut, Keep History retention, menu-bar/Dock presence, launch-at-login, iCloud sync, and Accessibility permission +- Right-click menu-bar status menu with capture state, history count, settings, manual or timed pause/resume, and quit - Global shortcuts: - - `Command + Option + V` toggles the clipboard panel + - `Shift + Command + V` or the configured open shortcut toggles the clipboard panel - `Command + ,` opens settings + - `Command + F` focuses search; press it again while search is active to show filters - `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text + - `Return` pastes the selected clip; `Shift + Return` or `Command + Shift + V` pastes it as plain text + - `Command + C` copies the selected clip or selected clips + - `Command + E` edits the selected text or code clip + - `Command + R` renames the selected clip + - `Delete` removes selected clips, and `Command + Z` restores the last deleted batch - `Command + G` shows a filtered result back in the full clipboard history + - `Command + O` opens the selected link, file, or media clip when possible + - `Command + N` creates a new text clip - `Shift + Command + N` creates a new collection - - `Space` previews the selected card when the focused search field is empty + - `Shift + Command + C` toggles Stack capture mode for queued multi-paste workflows + - `Command + Left` and `Command + Right` move between collections + - `Command + Up` and `Command + Down` jump to the first or last visible clip + - `Command + T` pauses or resumes clipboard capture + - `Command + A` selects the visible shelf from a focused card; `Shift` + shelf navigation extends the selected range + - `Space` or `Command + Y` previews the selected card when the focused search field is empty - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, video with movie thumbnails when available, RTF/HTML rich text, PDFs, and file references -- Keyboard-focusable cards and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints -- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End +- Keyboard-focusable compact rows and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, built-in browser previews for links, Quick Look for files and media, remote copied-on device metadata, image rotation and text extraction quick actions, vertical wheel/trackpad panning, visible focus chrome, and VoiceOver action hints +- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End; add `Shift` to extend the selected range - Shelf navigation keys for focused collection chips: Left/Right, Home, and End -- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads -- Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images +- SQLite persistence with bounded history, time-based retention, pinned-item and Pinboard retention, and encrypted app-managed payloads +- Search with independent token matching, a Paste-style filter menu, structured filters such as `app:Safari`, `type:image,pdf`, `device:MacBook`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, optional local OCR for copied images, and on-demand OCR from image cards - Sort modes for recent, most used, images, links, text, files, audio, and pinned items -- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu +- Custom named collections that behave as durable Pinboards, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, direct text-clip creation, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu +- Pinboard-level archive export from a collection chip for sharing one Pinboard, including empty Pinboards and color metadata +- Plain-text clip creation and editing with native macOS Writing Tools support when available - Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload -- Copy and paste actions with Accessibility permission fallback, including Stack next-item actions and Stack-as-text batch copy/paste +- Copy and paste actions with Accessibility permission fallback, including Command/Shift multi-select original-format or plain-text batching, Stack capture mode, Stack next-item actions, and Stack-as-text batch copy/paste - Image thumbnail cache with byte and file-count pruning -- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type -- Local-only storage, with optional sensitive-content exclusion for common secrets +- Portable local archive export/import for moving history, Pinboards, and app-managed image/audio/video/rich-text/PDF attachments between Macs; external file references stay path-based +- Optional iCloud archive sync for signed builds with an iCloud entitlement, using ClipBored's app-private ubiquity container and the same portable archive format +- Configurable left or right shelf side, Keep History retention, history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, screen-capture privacy, temporary capture pause, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type +- Local-first storage, with optional sensitive-content exclusion for common secrets and iCloud sync disabled by default ## Requirements @@ -42,7 +60,7 @@ swift test open build/ClipBored.app ``` -The build script packages `build/ClipBored.app`, strips the executable, applies an ad-hoc hardened-runtime signature, and enforces a 1 MiB executable gate plus a 1.8 MB bundle gate. +The build script packages `build/ClipBored.app`, strips the executable, applies an ad-hoc hardened-runtime signature, and enforces 2 MiB gates for both the executable and app bundle. ## Development @@ -78,9 +96,9 @@ Project layout: ## Privacy And Security -ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support. +ClipBored does not use telemetry or background networking. Clipboard history is stored locally under Application Support unless iCloud Sync is explicitly enabled in a signed build with an iCloud entitlement. User-triggered link previews load the selected web page in an ephemeral built-in WebKit view. -Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure. +Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Portable archive exports and iCloud sync archives are not encrypted by ClipBored, so treat them like sensitive backups. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion, ignored app settings, and the screen-sharing privacy toggle for high-risk sources or calls. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure. ## Contributing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb1d6a7..8497ac4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,9 +9,12 @@ ClipBored is a single-process AppKit utility built with Swift Package Manager. - `ClipboardMonitorService` polls `NSPasteboard.changeCount` on a utility queue with adaptive active/idle intervals. - `ClipboardStore` keeps the in-memory item list and persists rows to SQLite on a serial queue. - `ClipboardCacheService` stores bounded image previews under Application Support and keeps a small `NSCache`. +- `ClipboardCloudSyncService` resolves the app-private iCloud ubiquity container when sync is enabled and pushes or pulls the portable archive file. - `ShortcutManager` registers Carbon hotkeys for app-wide commands. -- `ClipboardPanelController` owns the bottom panel lifecycle and target-app tracking. +- `ClipboardPanelController` owns the side panel lifecycle, Dock-aware frame planning, configured left/right placement, and target-app tracking. - `ClipboardPanelViewModel` filters, sorts, selects, copies, pastes, pins, organizes, deletes, opens, and reveals items. +- `LinkPreviewWindowController` opens selected HTTP(S) links in an ephemeral WebKit preview window instead of handing them to another browser. +- `OnboardingWindowController` shows the first-run setup assistant for shortcut, retention, system entry points, launch-at-login, iCloud sync, and Accessibility permission choices. - `SettingsWindowController` exposes native controls for capture, privacy, performance, shortcuts, and data management. ## Data Flow @@ -20,7 +23,7 @@ ClipBored is a single-process AppKit utility built with Swift Package Manager. 2. Source app metadata is checked against ignored apps. 3. Pasteboard content is normalized into a `ClipboardItem`. 4. Sensitive text is skipped when exclusion is enabled. -5. Copied images run local Vision OCR only when `Search in image labels` is enabled. +5. Copied images run local Vision OCR only when `Search in image labels` is enabled; image cards can also run the same local OCR on demand from their quick actions. 6. The store deduplicates, preserves pinned and collection-assigned items, enforces limits, and persists the mutation. 7. The panel view model receives store updates and recomputes the visible list. @@ -46,10 +49,16 @@ Restorable non-image payloads such as audio clips, rich text, and PDFs are store Legacy JSON import still exists for migration from early builds. -Textual SQLite fields, including optional collection names and image OCR text, are encrypted and decrypted at the `ClipboardStore` boundary. App-managed image cache files, URL preview thumbnails, audio clips, rich text sidecars, and PDF attachments are encrypted and decrypted at the `ClipboardCacheService` boundary. The encryption key is stored in Keychain when available, with an owner-only app-local fallback key if Keychain access blocks or fails. Full history clears remove the local fallback key when present and reset cached key state after SQLite deletion succeeds. Runtime `ClipboardItem` values remain plaintext in memory so search, duplicate detection, copy, paste, organization, and cache cleanup operate normally. Opening or revealing encrypted media creates a temporary decrypted copy for macOS handoff; stale temporary previews are cleared on launch, cache/history clear, and quit. +Portable `.clipboredarchive` files are JSON exports created by `ClipboardArchiveService`. They preserve item metadata and include decrypted bytes for app-managed image, URL-thumbnail, audio, video, rich-text, and PDF sidecars so a different Mac can re-cache them under its own storage directory and encryption key. External file references remain path-based and are not copied into the archive. + +Optional iCloud Sync uses that same archive format at `Documents/ClipBored/ClipBored.clipboredarchive` inside the app-private ubiquity container. It is disabled by default, requires a signed build with iCloud entitlement access, pulls once when enabled at launch, and debounces pushes after local store changes. Shared Pinboard collaboration is not implemented. + +Textual SQLite fields, including optional collection names and image OCR text, are encrypted and decrypted at the `ClipboardStore` boundary. App-managed image cache files, URL preview thumbnails, audio clips, rich text sidecars, and PDF attachments are encrypted and decrypted at the `ClipboardCacheService` boundary. The encryption key is stored in Keychain when available, with an owner-only app-local fallback key if Keychain access blocks or fails. Full history clears remove the local fallback key when present and reset cached key state after SQLite deletion succeeds. Runtime `ClipboardItem` values remain plaintext in memory so search, duplicate detection, copy, paste, organization, archive export/import, and cache cleanup operate normally. Opening or revealing encrypted media creates a temporary decrypted copy for macOS handoff; stale temporary previews are cleared on launch, cache/history clear, and quit. Link previews are user-triggered and use a non-persistent WebKit data store. ## Size And Power Constraints The release build intentionally avoids SwiftUI, Combine, Swift Concurrency, third-party packages, bundled media, and app resources beyond `Info.plist`. -The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. The current public targets, enforced by `scripts/build-macos-app.sh`, are a 1 MiB executable and a 1.8 MB app bundle. +The side shelf is anchored to the current screen's visible frame and can be placed on the left or right edge. It uses compact horizontal rows in a vertical list; the active or hovered row expands in place while the panel stays clear of side Docks and reserves bottom Dock space for content padding. + +The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. The current public targets, enforced by `scripts/build-macos-app.sh`, are 2 MiB gates for both the executable and app bundle. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 2eaca24..2dd6888 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -10,7 +10,7 @@ Run: ./scripts/check.sh ``` -This runs the unit test suite, builds `build/ClipBored.app`, applies an ad-hoc hardened-runtime signature, enforces size gates, and verifies the app signature. +This runs the unit test suite, builds `build/ClipBored.app`, applies an ad-hoc hardened-runtime signature, enforces the 2 MiB executable and bundle size gates, and verifies the app signature. ## Local Archive @@ -40,6 +40,10 @@ export DEVELOPER_ID_APPLICATION="Developer ID Application: Example, Inc. (TEAMID The script rebuilds the app, re-signs it with hardened runtime and timestamping, verifies the signature, and writes `build/ClipBored.zip`. +## iCloud Sync Entitlements + +The default local and release scripts do not add iCloud entitlements. `Sync history with iCloud` will report unavailable in those builds. To ship iCloud Sync, sign with an entitlement file that grants the app's ubiquity container, then repeat the signature, smoke, and notarization checks. + ## Notarization Preferred: configure a notarytool keychain profile once: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fc1d536..42d03fa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -This roadmap keeps future work aligned with the project's constraints: small executable, low idle power, local-only storage, native macOS UI, and no feature regressions. +This roadmap keeps future work aligned with the project's constraints: small executable, low idle power, local-first storage, native macOS UI, and no feature regressions. ## Near Term @@ -10,12 +10,14 @@ This roadmap keeps future work aligned with the project's constraints: small exe ## Privacy And Security - Keep improving secure cleanup semantics for cleared cache/history/key material where macOS storage behavior allows it. -- Keep the current no-network/no-telemetry posture unless the project explicitly changes direction. +- Keep the current no-telemetry posture. Keep remote movement limited to explicit user-controlled sync/export paths. +- Add encrypted archive and iCloud-sync payload options before treating sync archives as safe for high-risk clipboard history. ## Product Polish - Improve keyboard focus states and VoiceOver labels. -- Add import/export only if the storage and privacy story remains clear. +- Consider optional password-protected archive exports if migration needs outgrow owner-only local archive files. +- Design true shared Pinboard collaboration separately from private iCloud archive sync. ## Performance diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 664bda9..6d82202 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,10 +1,10 @@ # Security Notes -ClipBored is designed as a local macOS utility. Its primary privacy promise is that clipboard data stays on the machine. +ClipBored is designed as a local-first macOS utility. Its default privacy promise is that clipboard data stays on the machine unless iCloud Sync is explicitly enabled. ## Current Protections -- No networking or telemetry in production source. +- No telemetry or background networking in production source. - No shell/process execution. - No Apple Events scripting. - Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured. @@ -14,7 +14,10 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t - If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall. - Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds. - App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes. +- Archive exports are written with owner-only permissions where supported. +- iCloud Sync is off by default and uses the app-private ubiquity container only when entitlement access is available. - ClipBored marks its own pasteboard writes so copy/paste actions from history are not re-captured as new clipboard events. +- The clipboard panel can be configured to opt out of screenshots, screen sharing, and screen recordings. - Sensitive-content exclusion can skip common high-risk values: - private key blocks - bearer tokens @@ -37,10 +40,14 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t - The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared. - Thumbnailing, opening, or revealing encrypted images, audio clips, video clips, or PDFs creates temporary decrypted preview files so macOS can hand them to system media APIs or other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit. - Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies. +- Portable `.clipboredarchive` files and iCloud sync archives are not encrypted by ClipBored. They include recoverable clipboard metadata and app-managed attachment bytes so they can be imported on another Mac; store and transmit them like sensitive backups. +- iCloud Sync relies on the user's private iCloud account and Apple's ubiquity container transport/storage. ClipBored does not add end-to-end archive encryption, conflict resolution beyond whole-archive import, or shared Pinboard access control. - The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds. - Accessibility permission is required for automatic paste simulation. +- Screen-sharing privacy applies to ClipBored's panel window, not to other apps, system clipboard state, or filesystem history. - Sensitive-content detection is heuristic and can miss novel formats or produce false positives. -- Local image OCR is opt-in through `Search in image labels`; recognized text stays local but can still contain sensitive clipboard-derived content. +- Automatic local image OCR is opt-in through `Search in image labels`; users can also run local OCR explicitly from an image card. Recognized text stays local but can still contain sensitive clipboard-derived content. +- User-triggered link preview loads the selected HTTP(S) URL in a non-persistent WebKit view. The destination site can still receive the request and normal browser-visible metadata for that preview load. - Local filesystem access by another process or user account with sufficient permissions can expose metadata, fallback keys, and live temporary decrypted previews. ## Release Hardening Checklist @@ -50,5 +57,5 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t - Verify `codesign --verify --deep --strict --verbose=2 build/ClipBored.app`. - Verify hardened runtime appears in `codesign -d --verbose=4 build/ClipBored.app`. - For distribution, verify `xcrun stapler validate build/ClipBored.app` and `spctl --assess --type execute --verbose=4 build/ClipBored.app`. -- Confirm no new `URLSession`, process execution, Apple Events, telemetry, or remote sync APIs were introduced. +- Confirm no new `URLSession`, process execution, Apple Events, or telemetry APIs were introduced; keep WebKit use limited to explicit link preview and keep sync limited to app-private iCloud ubiquity APIs. - Review any new persistence paths for unencrypted sensitive data. diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 5b51cd4..dbdaf9d 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -12,18 +12,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 2. Quit any running ClipBored copy. 3. Open `build/ClipBored.app`. -4. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled. +4. With a fresh preferences profile, confirm the setup assistant appears before the clipboard panel; choose the open shortcut, Keep History retention, menu-bar/Dock presence, launch-at-login, iCloud sync, and Accessibility option, then finish setup. +5. Reopen ClipBored and confirm the setup assistant does not reappear. +6. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled. ## Capture 1. Copy plain text from TextEdit, Notes, or a browser. -2. Open the panel with `Command + Option + V`. +2. Open the panel with the configured open shortcut. 3. Confirm the copied text appears in Most Recent. -4. Copy a URL and confirm it appears as a Link; if the source provides a local preview image, confirm the Link card uses that preview. -5. Copy an image and confirm it appears as an Image with a thumbnail. +4. Copy a URL and confirm it appears as a Link with a browser-style site preview; if the source provides a local preview image, confirm the Link card uses that image preview instead and that tall shelves give the preview more room. +5. Copy an image and confirm it appears as an Image with a thumbnail and a centered dimension overlay. 6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image. -7. Copy a sound clip and confirm it appears as Audio. -8. Copy a movie or video clip and confirm it appears as Video. +7. Copy a sound clip and confirm it appears as Audio with an album-style artwork tile. +8. Copy a movie or video clip and confirm it appears as Video with a player-style preview and centered format pill. 9. Copy a PDF or PDF selection and confirm it appears as a PDF. 10. Copy one Finder file and confirm it appears as a File. 11. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count. @@ -34,44 +36,54 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 1. Open the panel and confirm the search field is focused. 2. Type a query and confirm results filter immediately. -3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain. -4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query. +3. Press `Command + F` to focus search, press it again to show filters, then add Type, Device, and Pinboard filters and confirm they appear as structured tokens. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf device:` and confirm only clips from those collections, content types, and the copied-on Mac remain. +4. Clear the search field, press `Space`, and confirm a selected web link opens in ClipBored's built-in browser preview while non-link previewable clips open in Quick Look instead of inserting a blank query. 5. Use arrow keys to move selection while the search field is focused. -6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear. Use Left/Right, Home, and End to move through the chip rail, including custom collections and Stack when present. -7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for text, links, files, and media. -8. With a card focused, use Left/Right, Page Up/Page Down, Home, and End; confirm selection and focus move together across the shelf. -9. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered. -10. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden. -11. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. -12. Press `Esc` once with a non-empty search while the search field, a card, or a collection chip is focused and confirm search clears without closing the panel. -13. Press `Esc` again and confirm the panel closes. -14. Reopen the panel, change sort segments, and confirm each segment updates results. -15. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. -16. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. -17. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. -18. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. -19. Confirm collection chips with 0 clips do not show a visible count pill, while chips with clips still show their counts. -20. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. -21. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. -22. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. -23. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. -24. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. -25. Select a file, rich text, or URL card and confirm the selected-card rail exposes `Paste Plain Text`, the corner source/kind badge remains visible, and on a narrow shelf secondary actions collapse behind `More` instead of overflowing the card. -26. Confirm card footers do not show `Unknown` for clips without a source app, and confirm used clips show their usage count beside the source app. -27. Confirm card headers use readable relative ages such as `3 minutes ago` or `2 hours ago`, including when viewing a named collection. -28. Confirm the selected card shows a green corner Stack control, the action rail does not duplicate Stack, and clips added to Stack keep a visible corner indicator when selection moves away. -29. Confirm single-line text cards do not repeat the same text in both title and body, while multi-line text cards show the remaining lines below the first line. -30. Confirm the Pinned empty state points to the Pin action instead of a plain-key shortcut. -31. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon. -32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches. -33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable. -34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout. -35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view. -36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text. -37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text. -38. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data. -39. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order. -40. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack. +6. Tab to the search controls, collection `+`, toolbar buttons, and collection chips; confirm VoiceOver help explains the matching actions and shortcuts. Press `Space` or `Return` on a focused chip and confirm it is selected and the visible focus state is clear. Use Left/Right, Home, and End to move through the chip rail, including custom collections and Stack when present, and confirm VoiceOver help mentions those keys plus Pinboard/Stack context actions where available. +7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, `Command + C` copies it, `Shift + Return` and `Command + Shift + V` paste or copy it as plain text, and `Space` or `Command + Y` opens the built-in browser for web links or Quick Look for text, files, and media. +8. With a card focused, press `Command + F` and confirm focus returns to search; press `Command + E` on a text/code clip and confirm the edit dialog opens. On a Mac with Writing Tools available, confirm the dialog offers Writing Tools and keeps the saved clip as plain text. Press `Command + R` and confirm the rename dialog opens. +9. With a card focused, use Left/Right, Page Up/Page Down, Home, End, `Command + Up`, and `Command + Down`; confirm selection and focus move together across the shelf. Select two clips, press `Delete`, confirm both disappear, then press `Command + Z` and confirm both return selected. +10. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered. +11. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden, with collection-chip labels fading cleanly instead of hard-clipping at the utility buttons. +12. Right-click a filtered result and choose Show in Clipboard, or press `Command + G` with the result card focused, and confirm search clears while the same card stays selected in Most Recent. Press `Command + O` on a focused link, file, or media card and confirm it opens. +13. Press `Esc` once with a non-empty search while the search field, a card, or a collection chip is focused and confirm search clears without closing the panel. +14. Press `Esc` again and confirm the panel closes. +15. Reopen the panel, change sort segments, and confirm each segment updates results. +16. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. +17. Press `Command + N` or the shelf pencil button, enter text, and confirm a new text clip appears selected in the active shelf; on a Mac with Writing Tools available, confirm the new-text dialog offers Writing Tools. Repeat while Client Work is selected and confirm the clip is created in that collection. +18. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. +19. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. Confirm collection-assigned clips stay in Client Work even as normal clipboard history rolls over. +20. Create a second collection, then press `Command + Right` and `Command + Left`; confirm selection moves between collections and wraps around. +21. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. +22. Right-click a custom Pinboard chip, choose Export Pinboard..., save a `.clipboredarchive`, import it into a fresh test profile from Settings > Data, and confirm only that Pinboard's clips, empty Pinboard state, and color are restored. +23. Confirm collection chips with 0 clips do not show a visible count pill, while chips with clips still show their counts. +24. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. +25. Right-click an image card or focus its action rail, choose `Rotate Image`, and confirm the preview updates while the title, Pinboard, source app, and searchable image text remain. Then choose `Extract Text` on an image containing text and confirm the card shows the extracted text and `Copy Plain Text` can copy it. +26. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. +27. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. +28. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. +29. Open the panel and confirm it appears as a vertical side shelf with compact horizontal rows; hover a row and confirm it expands in place. On a multi-display setup, open from the global shortcut with the pointer on each display and confirm the shelf opens on that display; click the menu-bar icon and confirm it opens on the menu-bar icon's display. Switch Spaces or use a full-screen app and confirm the shelf opens on the active Space instead of following every desktop. +30. Switch Settings > General > Panel > Shelf side between Left and Right. Reopen the panel after each change and confirm it slides from the configured side and keeps the category row centered. +31. Select a file, rich text, or URL card, then hover or keyboard-focus it and confirm the action rail exposes `Paste Plain Text`, the corner source/kind badge remains visible, and on a narrow shelf secondary actions collapse behind `More` instead of overflowing the card. +32. Confirm card footers do not show `Unknown` for clips without a source app, confirm used clips show their usage count beside the source app, and confirm clips synced or imported from another Mac show that copied-on device in the footer. +33. Confirm card headers use readable relative ages such as `3 minutes ago` or `2 hours ago`, including when viewing a named collection. +34. Confirm the selected card shows a green corner Stack control, the hover/focus action rail does not duplicate Stack, and clips added to Stack keep a visible corner indicator when selection moves away. +35. Confirm text cards use a quiet paper-style body, single-line text cards do not repeat the same text in both title and body, and multi-line text cards show the remaining lines below the first line with a subtle bottom fade. +36. Confirm the Pinned empty state points to the Pin action instead of a plain-key shortcut. +37. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon. +38. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches. +39. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable. +40. Confirm single-file and PDF cards show a centered document-cover preview with a file-type pill, while multi-file cards show a stacked file preview. +41. Confirm the shelf chrome uses one row with compact search, collection chips, and separate soft utility icon buttons instead of a heavy grouped block; typing a search expands the search field without pushing cards out of view. +42. Confirm the top collection rail reads as a quiet translucent strip, the selected collection is a subtle pill, and the active card visibly floats above neighboring cards while the rest stay docked in the shelf. +43. Copy a color swatch from a design tool and confirm it appears as a centered paint-chip Color card, can be filtered with the Colors chip, and copies back as both a color and hex text. +44. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text. +45. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data. +46. Press `Shift + Command + C`, confirm the Stack chip appears active with 0 clips, copy two text snippets, and confirm Stack count increments and the Stack view shows them in copy order. Press `Shift + Command + C` again and confirm capture stops. +47. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order. +48. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack. +49. Command-click non-adjacent cards and Shift-click a range; confirm the status count changes to selected clips, selected cards remain highlighted while hovering, and the card menu offers Paste Selection, Copy Selection, Paste Selection as Text, Copy Selection as Text, and Add Selection to Stack. +50. With a card focused, press `Shift` with Left/Right, Page Up/Page Down, Home, or End to extend the range, then press `Command + A` and confirm every visible card is selected without changing the active card. ## Copy And Paste @@ -89,12 +101,15 @@ Use this checklist before a release or after changes to panel, pasteboard, setti ## Settings 1. Open Settings with `Command + ,`. -2. Change history length, default sort, polling profile, cache limit, ignored apps, and allowed content types; quit and reopen the app; confirm settings persist. +2. Change Shelf side, Keep History, history length, default sort, polling profile, cache limit, ignored apps, and allowed content types; quit and reopen the app; confirm settings persist. 3. Change the open-panel shortcut and confirm the old shortcut no longer opens the panel and the new shortcut does. -4. Toggle `Pause clipboard capture`, copy text, and confirm paused capture does not record it. +4. Toggle `Pause clipboard capture` or press `Command + T`, copy text, and confirm paused capture does not record it; press `Command + T` again and confirm capture resumes. 5. Toggle `Exclude likely secrets`, copy a representative token, and confirm it is not recorded. -6. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app. -7. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data. +6. Toggle `Hide panel from screen sharing and recordings`, open the clipboard panel, and confirm it is omitted from a macOS screenshot or screen recording; disable it and confirm capture works normally again. +7. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app. +8. Use Settings > Data > `Export Archive...`, save a `.clipboredarchive`, then import it into a fresh or cleared test profile and confirm text, Pinboard assignments, image previews, and PDF/audio/video/rich-text clips reappear; confirm external file-reference clips still point at their original paths. +9. In an ad-hoc local build, turn on Settings > Data > `Sync history with iCloud` and confirm the status reports iCloud Sync unavailable. In a signed build with an iCloud entitlement, use `Sync Now`, then restore into a fresh test profile and confirm text, Pinboard assignments, and app-managed attachments reappear. +10. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data. ## Storage And Privacy @@ -102,18 +117,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 2. Confirm `history.sqlite` exists after capture. 3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it. 4. Copy uniquely identifiable rich text/audio/video/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it. -5. If `history-encryption.key` exists, confirm it is readable only by the current user. -6. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`. -7. Confirm app storage is local to `~/Library/Application Support/ClipBored`. -8. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed. -9. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists. -10. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files. +5. Export an archive with unique test content and confirm the archive file is treated as sensitive backup material because it is portable and not encrypted by ClipBored. +6. If iCloud Sync is enabled in a signed build, confirm `ClipBored.clipboredarchive` in the app-private iCloud container is treated as sensitive backup material because it is portable and not encrypted by ClipBored. +7. If `history-encryption.key` exists, confirm it is readable only by the current user. +8. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`. +9. Confirm app storage is local to `~/Library/Application Support/ClipBored` when iCloud Sync is off. +10. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed. +11. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists. +12. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files. ## Launch And Lifecycle 1. Enable Launch at Login, log out and back in, and confirm ClipBored starts. 2. Disable Launch at Login and confirm it no longer starts after the next login. -3. Right-click the menu-bar icon and confirm the status menu opens with capture state, clip count, Show Clipboard, Settings, Pause/Resume Capture, and Quit. +3. Right-click the menu-bar icon and confirm the status menu opens with capture state, clip count, Show Clipboard, Settings, Pause/Resume Capture, Pause for 5 Minutes, Pause for 30 Minutes, Pause for 1 Hour, and Quit. 4. Control-click the menu-bar icon and confirm the same status menu opens without toggling the panel. -5. Toggle Pause/Resume Capture from the status menu and confirm the status row changes. -6. Quit ClipBored from the menu bar and confirm no `ClipBored` process remains. +5. Choose `Pause for 5 Minutes`, confirm the status detail shows a timed pause and copied text is not recorded, then choose `Resume Capture`. +6. Toggle manual Pause/Resume Capture from the status menu and confirm the status row changes. +7. Quit ClipBored from the menu bar and confirm no `ClipBored` process remains. diff --git a/docs/thingstofix.md b/docs/thingstofix.md new file mode 100644 index 0000000..1790ad6 --- /dev/null +++ b/docs/thingstofix.md @@ -0,0 +1,11 @@ +1. When hovering over a copied item, the options that appear on hover obscure the text. +2. When hovering over an item, key board arrow navigation breaks. +3. There needs to be some kind of animation? or smooothening effect when switching categories. +4. The search bar is broken when compressed, and clicking elsewhere should compress it again. +5. The search filter should just be the categories selected. Select categories with cmd. +6. There're a lot of categories, if there's nothing in them, don't show the categories by default. +7. There is a resize bar at the top lip of the window, don't bother with it. it doesn't allow resizing anywa.yz +8. Get rid of new text clip button. Don't need it. +9. Get rid of compact mode. +10. Get rid of close window. Don't need it. We use the macro from the keyboard. +11. Get rid of the status bar. diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index afa5c9b..c0d5f73 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,6 +10,8 @@ BIN_NAME="$APP_NAME" BIN_PATH="$REPO_ROOT/.build/release/$BIN_NAME" INFO_PLIST="$REPO_ROOT/sources/clipbored/resources/Info.plist" ICON_FILE="$REPO_ROOT/sources/clipbored/resources/AppIcon.icns" +SIZE_LIMIT_BYTES=$((2 * 1024 * 1024)) +SIZE_LIMIT_LABEL="2 MiB" cd "$REPO_ROOT" @@ -20,7 +22,8 @@ swift build -c release --product "$APP_NAME" \ -Xswiftc -Xfrontend \ -Xswiftc -disable-reflection-metadata \ -Xlinker -dead_strip \ - -Xlinker -no_function_starts + -Xlinker -no_function_starts \ + -Xlinker -no_compact_unwind rm -rf "$APP_BUNDLE" mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources" cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist" @@ -32,18 +35,17 @@ codesign --deep --force --options runtime --sign - "$APP_BUNDLE" >/dev/null 2>&1 touch "$APP_BUNDLE" APP_SIZE=$(stat -f%z "$APP_BUNDLE/Contents/MacOS/$APP_NAME") -APP_SIZE_LIMIT=$((1024 * 1024)) HUMAN_SIZE=$(du -h "$APP_BUNDLE/Contents/MacOS/$APP_NAME" | cut -f1) APP_BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1) APP_BUNDLE_BYTES=$(du -sk "$APP_BUNDLE" | awk '{print $1*1024}') echo "Built $APP_BUNDLE" echo "Binary size: $HUMAN_SIZE ($APP_SIZE bytes)" echo "Bundle size: $APP_BUNDLE_SIZE" -if [ "$APP_SIZE" -gt "$APP_SIZE_LIMIT" ]; then - echo "FAIL: executable exceeds 1MiB target ($APP_SIZE bytes)" +if [ "$APP_SIZE" -gt "$SIZE_LIMIT_BYTES" ]; then + echo "FAIL: executable exceeds $SIZE_LIMIT_LABEL target ($APP_SIZE bytes)" exit 1 fi -if [ "$APP_BUNDLE_BYTES" -gt 1800000 ]; then - echo "FAIL: bundle exceeds 1.8MB target ($APP_BUNDLE_BYTES bytes)" +if [ "$APP_BUNDLE_BYTES" -gt "$SIZE_LIMIT_BYTES" ]; then + echo "FAIL: bundle exceeds $SIZE_LIMIT_LABEL target ($APP_BUNDLE_BYTES bytes)" exit 1 fi diff --git a/sources/clipbored/app/AppDelegate.swift b/sources/clipbored/app/AppDelegate.swift index b8ee791..615de9a 100644 --- a/sources/clipbored/app/AppDelegate.swift +++ b/sources/clipbored/app/AppDelegate.swift @@ -17,31 +17,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let detail: String? } + struct CapturePauseDuration: Equatable { + let title: String + let seconds: TimeInterval + let symbolName: String + } + private static let statusMenuTextLimit = 68 + static let temporaryPauseDurations = [ + CapturePauseDuration(title: "Pause for 5 Minutes", seconds: 5 * 60, symbolName: "timer"), + CapturePauseDuration(title: "Pause for 30 Minutes", seconds: 30 * 60, symbolName: "timer"), + CapturePauseDuration(title: "Pause for 1 Hour", seconds: 60 * 60, symbolName: "clock") + ] private var cacheService: ClipboardCacheService! + private var cloudSyncService: ClipboardCloudSyncService! private var settings: SettingsModel! private var store: ClipboardStore! private var monitor: ClipboardMonitorService! private var panelController: ClipboardPanelController! private var settingsController: SettingsWindowController! + private var onboardingController: OnboardingWindowController? private var shortcutManager: ShortcutManager! private var lifecycleService: AppLifecycleService! private var statusItem: NSStatusItem? private var statusMenu: NSMenu? + private var pauseResumeTimer: Timer? + private var cloudSyncPushWorkItem: DispatchWorkItem? + private var suppressCloudSyncPush = false func applicationDidFinishLaunching(_ notification: Notification) { settings = SettingsModel() cacheService = ClipboardCacheService() + cloudSyncService = ClipboardCloudSyncService() store = ClipboardStore(settings: settings, cacheService: cacheService) monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings) panelController = ClipboardPanelController( store: store, settings: settings, cacheService: cacheService, - preferredScreen: { [weak self] in - self?.statusItem?.button?.window?.screen - }, pollClipboardNow: { [weak monitor] in monitor?.pollNowAndWait() }, @@ -49,7 +63,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.openSettings() } ) - settingsController = SettingsWindowController(settings: settings, store: store, cacheService: cacheService) + monitor.onCapturedItem = { [weak self] item in + self?.panelController.addCapturedItemToStack(item) + } + settingsController = SettingsWindowController( + settings: settings, + store: store, + cacheService: cacheService, + cloudSyncService: cloudSyncService + ) lifecycleService = AppLifecycleService() shortcutManager = ShortcutManager( onOpenClipboardPanel: { [weak self] in @@ -63,6 +85,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.settingsController.show() } }, + onToggleStackCapture: { [weak self] in + DispatchQueue.main.async { + self?.panelController.toggleStackCaptureMode() + } + }, onStatusChange: { [weak self] status in DispatchQueue.main.async { self?.settings.setShortcutStatus(message: status.message) @@ -72,8 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { settingsShortcut: settings.settingsShortcut ) bindSettings() + bindCloudSync() applyPresentation(changedSurface: nil) - monitor.setPaused(settings.pauseCapture) + applyCapturePauseSetting() monitor.start() shortcutManager.start() @@ -81,14 +109,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { refreshStatusItem() configureMainMenu() - requestInitialAccessibilityPermissionIfNeeded() + presentInitialSetupIfNeeded() } func applicationDidBecomeActive(_ notification: Notification) { refreshAccessibilityPermissionMessage() + onboardingController?.refreshPermissionStatus() } func applicationWillTerminate(_ notification: Notification) { + pauseResumeTimer?.invalidate() + cloudSyncPushWorkItem?.cancel() monitor.stop() shortcutManager.stop() cacheService.clearTemporaryPreviews(wait: true) @@ -108,6 +139,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panelController.toggle() } + @objc private func createCollection() { + panelController.createCollection() + } + + @objc private func toggleStackCaptureMode() { + panelController.toggleStackCaptureMode() + } + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { let event = NSApp.currentEvent if shouldOpenStatusMenu(for: event) { @@ -115,7 +154,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } - showClipboardPanel() + showClipboardPanelFromStatusButton(sender) + } + + private func showClipboardPanelFromStatusButton(_ button: NSStatusBarButton) { + panelController.toggle(preferredScreen: button.window?.screen) } private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool { @@ -143,6 +186,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { presentation: Self.statusMenuPresentation( historyCount: store.items.count, isCapturePaused: settings.pauseCapture, + pauseCaptureUntil: settings.pauseCaptureUntil, captureStatus: settings.captureStatusMessage, pasteStatus: settings.pasteStatusMessage, shortcutStatus: settings.shortcutStatusMessage, @@ -163,6 +207,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { static func statusMenuPresentation( historyCount: Int, isCapturePaused: Bool, + pauseCaptureUntil: Date? = nil, + now: Date = Date(), captureStatus: String, pasteStatus: String, shortcutStatus: String, @@ -172,7 +218,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let captureState = isCapturePaused ? "Capture Paused" : "Capture Running" let summary = "\(captureState) - \(clipCountText(historyCount))" let status = firstPresentStatus([ - isCapturePaused ? "Capture is paused." : nil, + capturePauseStatusText(isCapturePaused: isCapturePaused, pauseCaptureUntil: pauseCaptureUntil, now: now), captureStatus, pasteStatus, shortcutStatus, @@ -210,6 +256,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { symbolName: "rectangle.bottomthird.inset.filled", to: menu ) + addActionMenuItem( + "New Collection", + action: #selector(createCollection), + target: target, + keyEquivalent: "n", + keyEquivalentModifierMask: [.command, .shift], + symbolName: "folder.badge.plus", + to: menu + ) + addActionMenuItem( + "Stack Capture", + action: #selector(toggleStackCaptureMode), + target: target, + keyEquivalent: "c", + keyEquivalentModifierMask: [.command, .shift], + symbolName: "square.stack.3d.up.fill", + to: menu + ) addActionMenuItem( "Settings\u{2026}", action: #selector(openSettings), @@ -225,10 +289,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { isCapturePaused ? "Resume Capture" : "Pause Capture", action: #selector(togglePauseCapture), target: target, + keyEquivalent: "t", + keyEquivalentModifierMask: .command, symbolName: isCapturePaused ? "play.fill" : "pause.fill", to: menu ) pause.state = isCapturePaused ? .on : .off + if !isCapturePaused { + for duration in temporaryPauseDurations { + let item = addActionMenuItem( + duration.title, + action: #selector(pauseCaptureForDuration(_:)), + target: target, + symbolName: duration.symbolName, + to: menu + ) + item.representedObject = duration.seconds + } + } menu.addItem(NSMenuItem.separator()) addActionMenuItem( @@ -249,7 +327,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func togglePauseCapture() { - settings.pauseCapture.toggle() + if settings.pauseCapture { + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + } else { + settings.pauseCaptureUntil = nil + settings.pauseCapture = true + } + } + + @objc private func pauseCaptureForDuration(_ sender: NSMenuItem) { + let seconds = sender.representedObject as? TimeInterval ?? 0 + guard seconds > 0 else { return } + settings.pauseCapture = true + settings.pauseCaptureUntil = Date().addingTimeInterval(seconds) } @objc private func quitApp() { @@ -317,6 +408,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return "\(count) clips" } + static func shouldResumeExpiredCapturePause(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> Bool { + guard isCapturePaused, let pauseCaptureUntil else { return false } + return pauseCaptureUntil <= now + } + + static func capturePauseStatusText(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> String? { + guard isCapturePaused else { return nil } + guard let pauseCaptureUntil, pauseCaptureUntil > now else { + return "Capture is paused." + } + + let seconds = max(0, pauseCaptureUntil.timeIntervalSince(now)) + if seconds < 60 { + return "Capture is paused for less than a minute." + } + + let minutes = Int(ceil(seconds / 60)) + if minutes < 60 { + return "Capture is paused for \(minutes) more \(pluralized("minute", minutes))." + } + + let hours = Int(ceil(Double(minutes) / 60)) + return "Capture is paused for \(hours) more \(pluralized("hour", hours))." + } + + private static func pluralized(_ singular: String, _ count: Int) -> String { + count == 1 ? singular : "\(singular)s" + } + private static func boundedStatusText(_ value: String) -> String { let collapsed = value .split { $0.isWhitespace || $0.isNewline } @@ -385,6 +505,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appSubMenu.addItem(quit) appMenu.submenu = appSubMenu + let fileMenu = NSMenuItem() + let fileSubMenu = NSMenu(title: "File") + let newCollection = NSMenuItem( + title: "New Collection", + action: #selector(createCollection), + keyEquivalent: "n" + ) + newCollection.keyEquivalentModifierMask = [.command, .shift] + newCollection.target = self + fileSubMenu.addItem(newCollection) + fileSubMenu.addItem(NSMenuItem.separator()) + let pauseCapture = NSMenuItem( + title: "Pause/Resume Capture", + action: #selector(togglePauseCapture), + keyEquivalent: "t" + ) + pauseCapture.keyEquivalentModifierMask = .command + pauseCapture.target = self + fileSubMenu.addItem(pauseCapture) + fileMenu.submenu = fileSubMenu + let editMenu = NSMenuItem() let editSubMenu = NSMenu(title: "Edit") let openShortcut = self.settings.openShortcut @@ -396,10 +537,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut) showClipboard.target = self editSubMenu.addItem(showClipboard) + let stackCapture = NSMenuItem( + title: "Stack Capture", + action: #selector(toggleStackCaptureMode), + keyEquivalent: "c" + ) + stackCapture.keyEquivalentModifierMask = [.command, .shift] + stackCapture.target = self + editSubMenu.addItem(stackCapture) editMenu.submenu = editSubMenu let mainMenu = NSMenu() mainMenu.addItem(appMenu) + mainMenu.addItem(fileMenu) mainMenu.addItem(editMenu) NSApp.mainMenu = mainMenu } @@ -413,10 +563,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + private func bindCloudSync() { + var observedInitialItems = false + store.observeItems { [weak self] _ in + guard let self else { return } + if !observedInitialItems { + observedInitialItems = true + return + } + guard self.settings.iCloudSyncEnabled, !self.suppressCloudSyncPush else { return } + self.scheduleCloudSyncPush() + } + + if settings.iCloudSyncEnabled { + applyCloudSyncSetting() + } else { + settings.setCloudSyncStatus(message: "iCloud Sync is off.") + } + } + private func handleSettingsChange(_ change: SettingsModel.Change) { switch change { case .maxHistoryItems: store.updateHistoryLimit(settings.maxHistoryItems) + case .historyRetention: + store.normalizeHistoryLength() case .imageCacheMaxBytes: cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes) case .openShortcut, .settingsShortcut: @@ -430,20 +601,129 @@ final class AppDelegate: NSObject, NSApplicationDelegate { applyPresentation(changedSurface: .menuBar) case .showDockIcon: applyPresentation(changedSurface: .dock) + case .compactMode: + break + case .panelLayout: + break + case .panelSizing: + break + case .cloudSync: + applyCloudSyncSetting() case .pauseCapture: - monitor.setPaused(settings.pauseCapture) - if settings.showMenuBarIcon { - refreshStatusMenu() - } + applyCapturePauseSetting() case .pollProfile: monitor.setPaused(settings.pauseCapture) - case .status, .collections, .other: + case .hideFromScreenCapture: + break + case .defaultSortMode, .includeImageTextInSearch, .pruneDuplicates, .ignoredItemKinds, .keepFirstImage, .excludeSensitive, .clearHistoryOnQuit: + break + case .status, .collections, .ignoredApps, .other: break case .captureStatus: break } } + private func applyCloudSyncSetting() { + cloudSyncPushWorkItem?.cancel() + cloudSyncPushWorkItem = nil + + guard settings.iCloudSyncEnabled else { + settings.setCloudSyncStatus(message: "iCloud Sync is off.") + return + } + + let status = cloudSyncService.status() + settings.setCloudSyncStatus(message: status.message) + guard status.isAvailable else { return } + pullCloudSyncArchiveIfAvailable() + } + + private func pullCloudSyncArchiveIfAvailable() { + suppressCloudSyncPush = true + defer { suppressCloudSyncPush = false } + + do { + let summary = try cloudSyncService.pull(store: store) + settings.setCloudSyncStatus(message: "Restored \(summary.itemCount) clips from iCloud.") + } catch ClipboardCloudSyncError.noRemoteArchive(_) { + settings.setCloudSyncStatus(message: "iCloud Sync is ready. No remote archive yet.") + scheduleCloudSyncPush(after: 1.0) + } catch { + settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + + private func scheduleCloudSyncPush(after delay: TimeInterval = 2.0) { + cloudSyncPushWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.pushCloudSyncArchive() + } + cloudSyncPushWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func pushCloudSyncArchive() { + guard settings.iCloudSyncEnabled else { return } + do { + let summary = try cloudSyncService.push(store: store) + settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips to iCloud.") + } catch { + settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + + private func applyCapturePauseSetting(now: Date = Date()) { + if Self.shouldResumeExpiredCapturePause( + isCapturePaused: settings.pauseCapture, + pauseCaptureUntil: settings.pauseCaptureUntil, + now: now + ) { + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + return + } + + monitor.setPaused(settings.pauseCapture) + scheduleCapturePauseTimer(now: now) + if settings.showMenuBarIcon { + refreshStatusMenu() + } + } + + private func scheduleCapturePauseTimer(now: Date = Date()) { + pauseResumeTimer?.invalidate() + pauseResumeTimer = nil + + guard settings.pauseCapture, let pauseCaptureUntil = settings.pauseCaptureUntil else { return } + let interval = pauseCaptureUntil.timeIntervalSince(now) + guard interval > 0 else { + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + return + } + + pauseResumeTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.resumeExpiredCapturePause() + } + } + } + + private func resumeExpiredCapturePause() { + guard Self.shouldResumeExpiredCapturePause( + isCapturePaused: settings.pauseCapture, + pauseCaptureUntil: settings.pauseCaptureUntil, + now: Date() + ) else { + applyCapturePauseSetting() + return + } + + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + } + static func presentationPlan( showMenuBarIcon: Bool, showDockIcon: Bool, @@ -536,7 +816,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let alert = NSAlert() alert.messageText = "Allow automatic paste?" - alert.informativeText = "ClipBored can capture clipboard history without extra permission. Grant Accessibility only if you want selected clips to paste directly into the previous app; otherwise paste actions will copy the clip for you." + alert.informativeText = "ClipBored captures history without extra permission. Grant Accessibility only for direct paste; otherwise paste actions copy the clip." alert.addButton(withTitle: "Open Accessibility Settings") alert.addButton(withTitle: "Later") alert.alertStyle = .warning @@ -550,6 +830,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate { refreshAccessibilityPermissionMessage() } + private func presentInitialSetupIfNeeded() { + guard !settings.onboardingCompleted else { + requestInitialAccessibilityPermissionIfNeeded() + return + } + + let controller = OnboardingWindowController( + settings: settings, + onOpenAccessibility: { [weak self] in + self?.openAccessibilitySettingsFromOnboarding() + }, + onFinish: { [weak self] in + self?.completeInitialSetup() + } + ) + onboardingController = controller + controller.show() + } + + private func openAccessibilitySettingsFromOnboarding() { + settings.markAccessibilityNoticeShown() + _ = AccessibilityPermissionService.requestPromptIfNeeded() + if !AccessibilityPermissionService.isTrusted { + AccessibilityPermissionService.openSystemSettings() + } + refreshAccessibilityPermissionMessage() + } + + private func completeInitialSetup() { + onboardingController = nil + requestInitialAccessibilityPermissionIfNeeded() + } + private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags { NSEvent.ModifierFlags(rawValue: binding.modifierFlags) } diff --git a/sources/clipbored/config/AppConfiguration.swift b/sources/clipbored/config/AppConfiguration.swift index de2b93a..3524f39 100644 --- a/sources/clipbored/config/AppConfiguration.swift +++ b/sources/clipbored/config/AppConfiguration.swift @@ -7,7 +7,9 @@ enum AppConfiguration { static let defaultHistoryLength = 300 static let minHistoryLength = 50 static let maxHistoryLength = 2000 + static let minCacheMaxBytes: Int64 = 2 * 1024 * 1024 static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024 + static let maxCacheMaxBytes: Int64 = 512 * 1024 * 1024 static let maxPinnedItems = 250 static let maxFullImagePixelSize: CGFloat = 1600 static let maxRecognizedImageTextLength = 4096 diff --git a/sources/clipbored/extensions/NSImage+Helpers.swift b/sources/clipbored/extensions/NSImage+Helpers.swift index 60cd5ba..4eaa229 100644 --- a/sources/clipbored/extensions/NSImage+Helpers.swift +++ b/sources/clipbored/extensions/NSImage+Helpers.swift @@ -25,6 +25,42 @@ extension NSImage { let rep = NSBitmapImageRep(cgImage: cgImage) return rep.representation(using: .png, properties: [:]) } + + func rotatedClockwise() -> NSImage? { + guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil), + cgImage.width > 0, + cgImage.height > 0 else { + return nil + } + + let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + guard let context = CGContext( + data: nil, + width: cgImage.height, + height: cgImage.width, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + return nil + } + + context.interpolationQuality = .high + context.translateBy(x: CGFloat(cgImage.height), y: 0) + context.rotate(by: .pi / 2) + context.draw( + cgImage, + in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height) + ) + + guard let output = context.makeImage() else { return nil } + return NSImage( + cgImage: output, + size: NSSize(width: cgImage.height, height: cgImage.width) + ) + } } extension NSView { diff --git a/sources/clipbored/models/ClipboardItem.swift b/sources/clipbored/models/ClipboardItem.swift index f752c82..528d3e3 100644 --- a/sources/clipbored/models/ClipboardItem.swift +++ b/sources/clipbored/models/ClipboardItem.swift @@ -89,6 +89,31 @@ enum ClipboardSortMode: Int { case .videos: return "Videos" } } + + func includes(_ item: ClipboardItem) -> Bool { + switch self { + case .mostRecent, .mostUsed: + return true + case .images: + return item.kind == .image + case .links: + return item.kind == .url + case .text: + return item.kind == .text || item.kind == .richText || item.kind == .code + case .pinned: + return item.isPinned + case .files: + return item.kind == .file || item.kind == .pdf + case .audio: + return item.kind == .audio + case .colors: + return item.kind == .color + case .code: + return item.kind == .code + case .videos: + return item.kind == .video + } + } } enum ClipboardCollectionDefaults { @@ -127,6 +152,7 @@ struct ClipboardItem { var ocrText: String? var collectionName: String? var customTitle: String? + var sourceDeviceName: String? var searchableText: String { var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased() @@ -136,18 +162,22 @@ struct ClipboardItem { if let sourceApp { text += " " + sourceApp.lowercased() } - if let ocrText { - text += " " + ocrText.lowercased() - } if let sourceAppBundleId { text += " " + sourceAppBundleId.lowercased() } if let collectionName { text += " " + collectionName.lowercased() } + if let sourceDeviceName { + text += " " + sourceDeviceName.lowercased() + } return text } + var effectiveSourceDeviceName: String { + ClipboardItem.normalizedDeviceName(sourceDeviceName) ?? Self.localDeviceName + } + private var kindLabel: String { switch kind { case .text: return "text" @@ -180,7 +210,8 @@ struct ClipboardItem { sourceAppBundleId: String? = nil, ocrText: String? = nil, collectionName: String? = nil, - customTitle: String? = nil + customTitle: String? = nil, + sourceDeviceName: String? = ClipboardItem.localDeviceName ) { self.id = id self.kind = kind @@ -198,6 +229,7 @@ struct ClipboardItem { self.ocrText = ocrText self.collectionName = collectionName self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle) + self.sourceDeviceName = ClipboardItem.normalizedDeviceName(sourceDeviceName) } static func normalizedCustomTitle(_ value: String?) -> String? { @@ -209,4 +241,21 @@ struct ClipboardItem { guard !title.isEmpty else { return nil } return String(title.prefix(80)) } + + static var localDeviceName: String { + normalizedDeviceName(Host.current().localizedName) + ?? normalizedDeviceName(Host.current().name) + ?? normalizedDeviceName(ProcessInfo.processInfo.hostName) + ?? "This Mac" + } + + static func normalizedDeviceName(_ value: String?) -> String? { + guard let value else { return nil } + let name = value + .split { $0.isWhitespace } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + return String(name.prefix(60)) + } } diff --git a/sources/clipbored/models/LinkPreviewRequest.swift b/sources/clipbored/models/LinkPreviewRequest.swift new file mode 100644 index 0000000..596ca91 --- /dev/null +++ b/sources/clipbored/models/LinkPreviewRequest.swift @@ -0,0 +1,6 @@ +import Foundation + +struct LinkPreviewRequest: Equatable { + let url: URL + let title: String +} diff --git a/sources/clipbored/models/SettingsModel.swift b/sources/clipbored/models/SettingsModel.swift index 06ba347..d1df1f3 100644 --- a/sources/clipbored/models/SettingsModel.swift +++ b/sources/clipbored/models/SettingsModel.swift @@ -1,15 +1,75 @@ import Foundation +enum HistoryRetention: Int { + case forever = 0 + case oneDay = 1 + case oneWeek = 7 + case oneMonth = 30 + case oneYear = 365 + + static let allCases: [HistoryRetention] = [.oneDay, .oneWeek, .oneMonth, .oneYear, .forever] + + var title: String { + switch self { + case .oneDay: return "1 Day" + case .oneWeek: return "1 Week" + case .oneMonth: return "1 Month" + case .oneYear: return "1 Year" + case .forever: return "Forever" + } + } + + func cutoffDate(relativeTo now: Date = Date()) -> Date? { + guard self != .forever else { return nil } + return now.addingTimeInterval(-Double(rawValue) * 24 * 60 * 60) + } +} + +enum ClipboardPanelLayout: Int, CaseIterable { + case horizontal = 0 + case vertical = 1 + + var title: String { + "Side Shelf" + } +} + +enum ClipboardPanelSide: Int, CaseIterable { + case left = 0 + case right = 1 + + var title: String { + switch self { + case .left: return "Left" + case .right: return "Right" + } + } +} + final class SettingsModel { enum Change: Equatable { case maxHistoryItems + case historyRetention + case defaultSortMode case imageCacheMaxBytes + case includeImageTextInSearch + case pruneDuplicates case openShortcut case settingsShortcut case launchAtLogin case showMenuBarIcon case showDockIcon + case compactMode + case panelLayout + case panelSizing + case cloudSync case pauseCapture + case ignoredApps + case ignoredItemKinds + case keepFirstImage + case excludeSensitive + case hideFromScreenCapture + case clearHistoryOnQuit case pollProfile case captureStatus case collections @@ -19,6 +79,7 @@ final class SettingsModel { enum Keys { static let maxHistoryItems = "maxHistoryItems" + static let historyRetention = "historyRetentionDays" static let defaultSortMode = "defaultSortMode" static let imageCacheMaxBytes = "imageCacheMaxBytes" static let includeImageTextInSearch = "includeImageTextInSearch" @@ -26,6 +87,11 @@ final class SettingsModel { static let launchAtLogin = "launchAtLogin" static let showMenuBarIcon = "showMenuBarIcon" static let showDockIcon = "showDockIcon" + static let compactMode = "compactMode" + static let panelLayout = "panelLayout" + static let panelSide = "panelSide" + static let panelShelfHeight = "panelShelfHeight" + static let iCloudSyncEnabled = "iCloudSyncEnabled" static let openShortcut = "openShortcut" static let settingsShortcut = "settingsShortcut" static let ignoredApps = "ignoredApps" @@ -34,26 +100,38 @@ final class SettingsModel { static let keepFirstImage = "keepFirstImage" static let excludeSensitive = "excludeSensitive" static let pauseCapture = "pauseCapture" + static let pauseCaptureUntil = "pauseCaptureUntil" + static let hideFromScreenCapture = "hideFromScreenCapture" static let clearHistoryOnQuit = "clearHistoryOnQuit" + static let onboardingCompleted = "onboardingCompleted" static let accessibilityNoticeShown = "accessibilityNoticeShown" static let customCollectionNames = "customCollectionNames" static let collectionColorHexes = "collectionColorHexes" } var maxHistoryItems: Int { - didSet { if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } } + didSet { + maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems) + if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } + } + } + var historyRetention: HistoryRetention { + didSet { if oldValue != historyRetention { storeAndNotify(.historyRetention) } } } var defaultSortMode: ClipboardSortMode { - didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } } + didSet { if oldValue != defaultSortMode { storeAndNotify(.defaultSortMode) } } } var imageCacheMaxBytes: Int64 { - didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } } + didSet { + imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes) + if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } + } } var includeImageTextInSearch: Bool { - didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } } + didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.includeImageTextInSearch) } } } var pruneDuplicates: Bool { - didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } } + didSet { if oldValue != pruneDuplicates { storeAndNotify(.pruneDuplicates) } } } var launchAtLogin: Bool { didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } } @@ -64,6 +142,30 @@ final class SettingsModel { var showDockIcon: Bool { didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } } } + var compactMode: Bool { + didSet { if oldValue != compactMode { storeAndNotify(.compactMode) } } + } + var panelLayout: ClipboardPanelLayout { + didSet { + if panelLayout != .vertical { + panelLayout = .vertical + } + if oldValue != panelLayout { storeAndNotify(.panelLayout) } + } + } + var panelSide: ClipboardPanelSide { + didSet { if oldValue != panelSide { storeAndNotify(.panelLayout) } } + } + var panelShelfHeight: Double { + didSet { if oldValue != panelShelfHeight { storeAndNotify(.panelSizing) } } + } + var iCloudSyncEnabled: Bool { + didSet { + guard oldValue != iCloudSyncEnabled else { return } + cloudSyncStatusMessage = "" + storeAndNotify(.cloudSync) + } + } var openShortcut: ShortcutBinding { didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } } } @@ -71,25 +173,37 @@ final class SettingsModel { didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } } } var ignoredApps: [String] { - didSet { if oldValue != ignoredApps { storeAndNotify(.other) } } + didSet { if oldValue != ignoredApps { storeAndNotify(.ignoredApps) } } } var ignoredItemKindsRaw: [Int] { - didSet { if oldValue != ignoredItemKindsRaw { storeAndNotify(.other) } } + didSet { + let normalized = Self.normalizedIgnoredItemKinds(ignoredItemKindsRaw) + if normalized != ignoredItemKindsRaw { + ignoredItemKindsRaw = normalized + } + if oldValue != ignoredItemKindsRaw { storeAndNotify(.ignoredItemKinds) } + } } var pollProfileRaw: AppConfiguration.PollProfile { didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } } } var keepFirstImage: Bool { - didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } } + didSet { if oldValue != keepFirstImage { storeAndNotify(.keepFirstImage) } } } var excludeSensitive: Bool { - didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } } + didSet { if oldValue != excludeSensitive { storeAndNotify(.excludeSensitive) } } } var pauseCapture: Bool { didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } } } + var pauseCaptureUntil: Date? { + didSet { if oldValue != pauseCaptureUntil { storeAndNotify(.pauseCapture) } } + } + var hideFromScreenCapture: Bool { + didSet { if oldValue != hideFromScreenCapture { storeAndNotify(.hideFromScreenCapture) } } + } var clearHistoryOnQuit: Bool { - didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } } + didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.clearHistoryOnQuit) } } } private(set) var customCollectionNames: [String] private(set) var collectionColorHexes: [String: String] @@ -98,6 +212,8 @@ final class SettingsModel { private(set) var captureStatusMessage: String = "" private(set) var shortcutStatusMessage: String = "" private(set) var pasteStatusMessage: String = "" + private(set) var cloudSyncStatusMessage: String = "" + private(set) var onboardingCompleted: Bool private(set) var accessibilityNoticeShown: Bool private let defaults: UserDefaults @@ -107,10 +223,17 @@ final class SettingsModel { self.defaults = defaults let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems) + let savedRetention = defaults.object(forKey: Keys.historyRetention) as? Int let savedSort = defaults.integer(forKey: Keys.defaultSortMode) + let savedCacheObject = defaults.object(forKey: Keys.imageCacheMaxBytes) let savedCache = defaults.integer(forKey: Keys.imageCacheMaxBytes) + let savedPanelSide = defaults.object(forKey: Keys.panelSide) as? Int + let existingProfile = defaults.object(forKey: Keys.maxHistoryItems) != nil + || defaults.object(forKey: Keys.historyRetention) != nil + || defaults.object(forKey: Keys.openShortcut) != nil maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength + historyRetention = savedRetention.flatMap(HistoryRetention.init(rawValue:)) ?? .oneMonth defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false @@ -118,30 +241,57 @@ final class SettingsModel { launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false + compactMode = defaults.object(forKey: Keys.compactMode) as? Bool ?? false + panelLayout = .vertical + panelSide = savedPanelSide.flatMap(ClipboardPanelSide.init(rawValue:)) ?? .right + panelShelfHeight = defaults.object(forKey: Keys.panelShelfHeight) as? Double ?? 0 + iCloudSyncEnabled = defaults.object(forKey: Keys.iCloudSyncEnabled) as? Bool ?? false openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps - ignoredItemKindsRaw = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? [] + let storedIgnoredItemKinds = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? [] + ignoredItemKindsRaw = Self.normalizedIgnoredItemKinds(storedIgnoredItemKinds) let profileValue = defaults.integer(forKey: Keys.pollProfile) pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false + if let pauseUntilValue = defaults.object(forKey: Keys.pauseCaptureUntil) as? TimeInterval, + pauseUntilValue > 0 { + pauseCaptureUntil = Date(timeIntervalSince1970: pauseUntilValue) + } else { + pauseCaptureUntil = nil + } + hideFromScreenCapture = defaults.object(forKey: Keys.hideFromScreenCapture) as? Bool ?? false clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? []) collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes)) + onboardingCompleted = defaults.object(forKey: Keys.onboardingCompleted) as? Bool ?? existingProfile accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false - maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) - imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes) + maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems) + imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes) + if panelShelfHeight < 0 { + panelShelfHeight = 0 + } - if defaults.object(forKey: Keys.maxHistoryItems) == nil { + if defaults.object(forKey: Keys.maxHistoryItems) == nil + || savedHistory != maxHistoryItems + || defaults.object(forKey: Keys.historyRetention) == nil + || savedCacheObject == nil + || savedCache <= 0 + || Int64(savedCache) != imageCacheMaxBytes + || storedIgnoredItemKinds != ignoredItemKindsRaw + || defaults.object(forKey: Keys.panelLayout) as? Int != ClipboardPanelLayout.vertical.rawValue + || savedPanelSide == nil + || ClipboardPanelSide(rawValue: savedPanelSide ?? -1) == nil { store() } } private func store() { defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems) + defaults.set(historyRetention.rawValue, forKey: Keys.historyRetention) defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode) defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes) defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch) @@ -149,6 +299,11 @@ final class SettingsModel { defaults.set(launchAtLogin, forKey: Keys.launchAtLogin) defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon) defaults.set(showDockIcon, forKey: Keys.showDockIcon) + defaults.set(compactMode, forKey: Keys.compactMode) + defaults.set(panelLayout.rawValue, forKey: Keys.panelLayout) + defaults.set(panelSide.rawValue, forKey: Keys.panelSide) + defaults.set(panelShelfHeight, forKey: Keys.panelShelfHeight) + defaults.set(iCloudSyncEnabled, forKey: Keys.iCloudSyncEnabled) defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut) defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut) defaults.set(ignoredApps, forKey: Keys.ignoredApps) @@ -157,7 +312,14 @@ final class SettingsModel { defaults.set(keepFirstImage, forKey: Keys.keepFirstImage) defaults.set(excludeSensitive, forKey: Keys.excludeSensitive) defaults.set(pauseCapture, forKey: Keys.pauseCapture) + if let pauseCaptureUntil { + defaults.set(pauseCaptureUntil.timeIntervalSince1970, forKey: Keys.pauseCaptureUntil) + } else { + defaults.removeObject(forKey: Keys.pauseCaptureUntil) + } + defaults.set(hideFromScreenCapture, forKey: Keys.hideFromScreenCapture) defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit) + defaults.set(onboardingCompleted, forKey: Keys.onboardingCompleted) defaults.set(customCollectionNames, forKey: Keys.customCollectionNames) defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes) } @@ -201,6 +363,13 @@ final class SettingsModel { defaults.set(true, forKey: Keys.accessibilityNoticeShown) } + func markOnboardingCompleted() { + guard !onboardingCompleted else { return } + onboardingCompleted = true + defaults.set(true, forKey: Keys.onboardingCompleted) + notify(.other) + } + func setShortcutStatus(message: String) { guard shortcutStatusMessage != message else { return } shortcutStatusMessage = message @@ -213,6 +382,12 @@ final class SettingsModel { notify(.status) } + func setCloudSyncStatus(message: String) { + guard cloudSyncStatusMessage != message else { return } + cloudSyncStatusMessage = message + notify(.status) + } + @discardableResult func ensureCollection(named name: String, colorHex: String? = nil) -> String? { guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil } @@ -334,8 +509,16 @@ final class SettingsModel { } func sanitizeLimits() { - maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems)) - imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes) + maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems) + imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes) + } + + private static func clampedMaxHistoryItems(_ count: Int) -> Int { + max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, count)) + } + + private static func clampedImageCacheMaxBytes(_ bytes: Int64) -> Int64 { + max(AppConfiguration.minCacheMaxBytes, min(AppConfiguration.maxCacheMaxBytes, bytes)) } private static func normalizedCollectionNames(_ names: [String]) -> [String] { @@ -348,6 +531,25 @@ final class SettingsModel { return normalized } + private static let userVisibleItemKindRawValues: Set = [ + ClipboardItemKind.text.rawValue, + ClipboardItemKind.code.rawValue, + ClipboardItemKind.url.rawValue, + ClipboardItemKind.image.rawValue, + ClipboardItemKind.color.rawValue, + ClipboardItemKind.audio.rawValue, + ClipboardItemKind.video.rawValue, + ClipboardItemKind.richText.rawValue, + ClipboardItemKind.pdf.rawValue, + ClipboardItemKind.file.rawValue + ] + + private static func normalizedIgnoredItemKinds(_ values: [Int]) -> [Int] { + let ignoredVisibleKinds = Set(values).intersection(userVisibleItemKindRawValues) + guard userVisibleItemKindRawValues.isSubset(of: ignoredVisibleKinds) else { return values } + return values.filter { $0 != ClipboardItemKind.text.rawValue } + } + private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] { guard let rawValue else { return [:] } var normalized: [String: String] = [:] diff --git a/sources/clipbored/models/VideoPayload.swift b/sources/clipbored/models/VideoPayload.swift index ee61aab..85aef13 100644 --- a/sources/clipbored/models/VideoPayload.swift +++ b/sources/clipbored/models/VideoPayload.swift @@ -1,13 +1,19 @@ import AppKit import Foundation -import UniformTypeIdentifiers enum VideoPayload { + private enum TypeIdentifier { + static let mpeg4Movie = "public.mpeg-4" + static let quickTimeMovie = "com.apple.quicktime-movie" + static let movie = "public.movie" + static let video = "public.video" + } + static let pasteboardTypes: [NSPasteboard.PasteboardType] = [ - NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier), - NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier), - NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier), - NSPasteboard.PasteboardType(rawValue: UTType.video.identifier), + NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie), + NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie), + NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie), + NSPasteboard.PasteboardType(rawValue: TypeIdentifier.video), NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video") ] @@ -22,9 +28,9 @@ enum VideoPayload { static func fileExtension(for type: NSPasteboard.PasteboardType) -> String { switch type.rawValue { - case UTType.mpeg4Movie.identifier: + case TypeIdentifier.mpeg4Movie: return "mp4" - case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier: + case TypeIdentifier.quickTimeMovie, TypeIdentifier.movie, TypeIdentifier.video: return "mov" case "com.apple.m4v-video": return "m4v" @@ -36,13 +42,13 @@ enum VideoPayload { static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType { switch URL(fileURLWithPath: path).pathExtension.lowercased() { case "mp4": - return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier) + return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie) case "m4v": return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video") case "mov", "qt": - return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier) + return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie) default: - return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier) + return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie) } } diff --git a/sources/clipbored/services/ClipboardArchiveService.swift b/sources/clipbored/services/ClipboardArchiveService.swift new file mode 100644 index 0000000..a6797f4 --- /dev/null +++ b/sources/clipbored/services/ClipboardArchiveService.swift @@ -0,0 +1,328 @@ +import Foundation + +struct ClipboardArchiveSummary: Equatable { + let itemCount: Int + let sidecarCount: Int + let skippedItemCount: Int + let skippedSidecarCount: Int +} + +struct ClipboardArchiveImport { + let items: [ClipboardItem] + let collections: [ClipboardArchiveCollection] + let summary: ClipboardArchiveSummary +} + +struct ClipboardArchiveCollection: Codable, Equatable { + let name: String + let colorHex: String? +} + +enum ClipboardArchiveError: LocalizedError { + case unsupportedVersion(Int) + case invalidArchive + + var errorDescription: String? { + switch self { + case .unsupportedVersion(let version): + return "This ClipBored archive uses unsupported format version \(version)." + case .invalidArchive: + return "The selected file is not a valid ClipBored archive." + } + } +} + +final class ClipboardArchiveService { + static let fileExtension = "clipboredarchive" + private static let currentFormatVersion = 1 + + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func exportArchive( + items: [ClipboardItem], + to url: URL, + cacheService: ClipboardCacheService, + collections: [ClipboardArchiveCollection] = [] + ) throws -> ClipboardArchiveSummary { + var sidecarCount = 0 + let archivedItems = items.map { item -> ArchiveItem in + let sidecars = archivedSidecars(for: item, cacheService: cacheService) + sidecarCount += sidecars.count + return ArchiveItem(item: item, sidecars: sidecars) + } + + let archive = ArchivePayload( + formatVersion: Self.currentFormatVersion, + createdBy: AppConfiguration.appName, + exportedAt: Date(), + collections: collections, + items: archivedItems + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(archive) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + + return ClipboardArchiveSummary( + itemCount: items.count, + sidecarCount: sidecarCount, + skippedItemCount: 0, + skippedSidecarCount: 0 + ) + } + + func importArchive( + from url: URL, + cacheService: ClipboardCacheService + ) throws -> ClipboardArchiveImport { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let archive: ArchivePayload + do { + archive = try decoder.decode(ArchivePayload.self, from: data) + } catch { + throw ClipboardArchiveError.invalidArchive + } + guard archive.formatVersion <= Self.currentFormatVersion else { + throw ClipboardArchiveError.unsupportedVersion(archive.formatVersion) + } + + var importedItems: [ClipboardItem] = [] + var sidecarCount = 0 + var skippedItemCount = 0 + var skippedSidecarCount = 0 + importedItems.reserveCapacity(archive.items.count) + + for archivedItem in archive.items { + guard var item = archivedItem.clipboardItem() else { + skippedItemCount += 1 + continue + } + + for sidecar in archivedItem.sidecars { + switch sidecar.role { + case .image: + if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id) { + item.imagePath = path + if item.kind == .image { + item.payload = path + } + sidecarCount += 1 + } else { + skippedSidecarCount += 1 + } + + case .thumbnail: + if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id, fileNamePrefix: "thumb") { + item.thumbnailPath = path + sidecarCount += 1 + } else { + skippedSidecarCount += 1 + } + + case .attachment: + if let path = cacheService.cacheAttachmentData( + sidecar.data, + id: item.id, + fileExtension: sidecar.fileExtension + ) { + if item.kind.usesManagedPayloadAttachment { + item.payload = path + } + sidecarCount += 1 + } else { + skippedSidecarCount += 1 + } + } + } + + importedItems.append(item) + } + + let summary = ClipboardArchiveSummary( + itemCount: importedItems.count, + sidecarCount: sidecarCount, + skippedItemCount: skippedItemCount, + skippedSidecarCount: skippedSidecarCount + ) + return ClipboardArchiveImport( + items: importedItems, + collections: archive.collections ?? [], + summary: summary + ) + } + + private func archivedSidecars( + for item: ClipboardItem, + cacheService: ClipboardCacheService + ) -> [ArchiveSidecar] { + var sidecars: [ArchiveSidecar] = [] + var archivedPaths = Set() + + func append(_ role: ArchiveSidecarRole, path: String?, fallbackExtension: String) { + guard let path, !path.clipboardTrimmed.isEmpty, !archivedPaths.contains(path) else { return } + guard let data = cacheService.data(for: path) else { return } + archivedPaths.insert(path) + sidecars.append( + ArchiveSidecar( + role: role, + fileExtension: fileExtension(for: path, fallback: fallbackExtension), + data: data + ) + ) + } + + switch item.kind { + case .image: + append(.image, path: item.imagePath ?? item.payload, fallbackExtension: "png") + append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png") + + case .url: + append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png") + + case .pdf: + append(.attachment, path: item.payload, fallbackExtension: "pdf") + + case .audio: + append(.attachment, path: item.payload, fallbackExtension: "sound") + + case .richText: + append(.attachment, path: item.payload, fallbackExtension: "rtf") + + case .video: + append(.attachment, path: item.payload, fallbackExtension: VideoPayload.fileExtension(from: item.payload)) + + case .text, .file, .unknown, .color, .code: + break + } + + return sidecars + } + + private func fileExtension(for path: String, fallback: String) -> String { + let ext = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed + return ext.isEmpty ? fallback : ext + } +} + +private struct ArchivePayload: Codable { + let formatVersion: Int + let createdBy: String + let exportedAt: Date + let collections: [ClipboardArchiveCollection]? + let items: [ArchiveItem] + + init( + formatVersion: Int, + createdBy: String, + exportedAt: Date, + collections: [ClipboardArchiveCollection], + items: [ArchiveItem] + ) { + self.formatVersion = formatVersion + self.createdBy = createdBy + self.exportedAt = exportedAt + self.collections = collections.isEmpty ? nil : collections + self.items = items + } +} + +private struct ArchiveItem: Codable { + let id: UUID + let kind: Int + let displayText: String + let payload: String + let payloadHash: String + let createdAt: Date + let lastUsedAt: Date + let useCount: Int + let sourceApp: String? + let imagePath: String? + let thumbnailPath: String? + let isPinned: Bool + let sourceAppBundleId: String? + let ocrText: String? + let collectionName: String? + let customTitle: String? + let sourceDeviceName: String? + let sidecars: [ArchiveSidecar] + + init(item: ClipboardItem, sidecars: [ArchiveSidecar]) { + id = item.id + kind = item.kind.rawValue + displayText = item.displayText + payload = item.payload + payloadHash = item.payloadHash + createdAt = item.createdAt + lastUsedAt = item.lastUsedAt + useCount = item.useCount + sourceApp = item.sourceApp + imagePath = item.imagePath + thumbnailPath = item.thumbnailPath + isPinned = item.isPinned + sourceAppBundleId = item.sourceAppBundleId + ocrText = item.ocrText + collectionName = item.collectionName + customTitle = item.customTitle + sourceDeviceName = item.sourceDeviceName + self.sidecars = sidecars + } + + func clipboardItem() -> ClipboardItem? { + guard let kind = ClipboardItemKind(rawValue: kind) else { return nil } + return ClipboardItem( + id: id, + kind: kind, + displayText: displayText, + payload: payload, + payloadHash: payloadHash, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + useCount: useCount, + sourceApp: sourceApp, + imagePath: imagePath, + thumbnailPath: thumbnailPath, + isPinned: isPinned, + sourceAppBundleId: sourceAppBundleId, + ocrText: ocrText, + collectionName: collectionName, + customTitle: customTitle, + sourceDeviceName: sourceDeviceName + ) + } +} + +private struct ArchiveSidecar: Codable { + let role: ArchiveSidecarRole + let fileExtension: String + let data: Data +} + +private enum ArchiveSidecarRole: String, Codable { + case image + case thumbnail + case attachment +} + +private extension ClipboardItemKind { + var usesManagedPayloadAttachment: Bool { + switch self { + case .pdf, .audio, .richText, .video: + return true + case .text, .url, .image, .file, .unknown, .color, .code: + return false + } + } +} diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index 1eac0d9..5b6756b 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -57,19 +57,46 @@ final class ClipboardCacheService { } func cachePDF(_ data: Data, id: UUID) -> String? { - cacheAttachment(data, id: id, fileExtension: "pdf") + cacheAttachmentData(data, id: id, fileExtension: "pdf") } func cacheAudio(_ data: Data, id: UUID) -> String? { - cacheAttachment(data, id: id, fileExtension: "sound") + cacheAttachmentData(data, id: id, fileExtension: "sound") } func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? { - cacheAttachment(data, id: id, fileExtension: fileExtension) + cacheAttachmentData(data, id: id, fileExtension: fileExtension) } func cacheRichText(_ data: Data, id: UUID) -> String? { - cacheAttachment(data, id: id, fileExtension: "rtf") + cacheAttachmentData(data, id: id, fileExtension: "rtf") + } + + func cacheAttachmentData(_ data: Data, id: UUID, fileExtension: String) -> String? { + let sanitizedExtension = fileExtension + .split { $0 == "." || $0 == "/" || $0 == "\\" } + .last + .map(String.init) ?? "dat" + let normalizedExtension = sanitizedExtension.clipboardTrimmed.isEmpty ? "dat" : sanitizedExtension + return cacheAttachment(data, id: id, fileExtension: normalizedExtension) + } + + func cacheImageSidecarData(_ data: Data, id: UUID, fileNamePrefix: String? = nil) -> String? { + let prefix = fileNamePrefix?.clipboardTrimmed ?? "" + let fileName = prefix.isEmpty + ? "\(id.uuidString).png" + : "\(prefix)-\(id.uuidString).png" + let url = imageDirectory.appendingPathComponent(fileName) + do { + try encrypted(data).write(to: url, options: .atomic) + hardenFile(url) + if let image = thumbImage(data) { + thumbnailCache.setObject(image, forKey: url.path as NSString) + } + return url.path + } catch { + return nil + } } private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? { diff --git a/sources/clipbored/services/ClipboardCloudSyncService.swift b/sources/clipbored/services/ClipboardCloudSyncService.swift new file mode 100644 index 0000000..9a90e3a --- /dev/null +++ b/sources/clipbored/services/ClipboardCloudSyncService.swift @@ -0,0 +1,111 @@ +import Foundation + +struct ClipboardCloudSyncStatus: Equatable { + let isAvailable: Bool + let archiveURL: URL? + let lastModifiedAt: Date? + let message: String +} + +protocol ClipboardCloudSyncServicing { + func syncArchiveURL() throws -> URL + func status() -> ClipboardCloudSyncStatus + @discardableResult + func push(store: ClipboardStore) throws -> ClipboardArchiveSummary + @discardableResult + func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary +} + +enum ClipboardCloudSyncError: LocalizedError, Equatable { + case unavailable + case noRemoteArchive(URL) + + var errorDescription: String? { + switch self { + case .unavailable: + return "iCloud Sync is unavailable. Sign ClipBored with an iCloud container entitlement and make sure iCloud Drive is enabled." + case .noRemoteArchive: + return "No ClipBored iCloud archive has been created yet." + } + } +} + +final class ClipboardCloudSyncService: ClipboardCloudSyncServicing { + static let archiveFileName = "ClipBored.\(ClipboardArchiveService.fileExtension)" + + private let fileManager: FileManager + private let containerProvider: () -> URL? + + init( + fileManager: FileManager = .default, + containerProvider: @escaping () -> URL? = { + FileManager.default.url(forUbiquityContainerIdentifier: nil) + } + ) { + self.fileManager = fileManager + self.containerProvider = containerProvider + } + + func syncArchiveURL() throws -> URL { + guard let containerURL = containerProvider() else { + throw ClipboardCloudSyncError.unavailable + } + + let directory = containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent(AppConfiguration.appName, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + return directory.appendingPathComponent(Self.archiveFileName) + } + + func status() -> ClipboardCloudSyncStatus { + do { + let url = try syncArchiveURL() + let attributes = try? fileManager.attributesOfItem(atPath: url.path) + let lastModifiedAt = attributes?[.modificationDate] as? Date + let message: String + if lastModifiedAt != nil { + message = "iCloud Sync is ready." + } else { + message = "iCloud Sync is ready. No remote archive yet." + } + return ClipboardCloudSyncStatus( + isAvailable: true, + archiveURL: url, + lastModifiedAt: lastModifiedAt, + message: message + ) + } catch { + return ClipboardCloudSyncStatus( + isAvailable: false, + archiveURL: nil, + lastModifiedAt: nil, + message: error.localizedDescription + ) + } + } + + @discardableResult + func push(store: ClipboardStore) throws -> ClipboardArchiveSummary { + let url = try syncArchiveURL() + return try store.exportArchive(to: url) + } + + @discardableResult + func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary { + let url = try syncArchiveURL() + guard fileManager.fileExists(atPath: url.path) else { + throw ClipboardCloudSyncError.noRemoteArchive(url) + } + + startDownloadingIfNeeded(url) + return try store.importArchive(from: url) + } + + private func startDownloadingIfNeeded(_ url: URL) { + let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey]) + guard values?.isUbiquitousItem == true else { return } + try? fileManager.startDownloadingUbiquitousItem(at: url) + } +} diff --git a/sources/clipbored/services/ClipboardEncryptionService.swift b/sources/clipbored/services/ClipboardEncryptionService.swift index de4d818..ae10126 100644 --- a/sources/clipbored/services/ClipboardEncryptionService.swift +++ b/sources/clipbored/services/ClipboardEncryptionService.swift @@ -20,8 +20,13 @@ final class ClipboardEncryptionService { private let resetProvider: () -> Void init() { - keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() } - resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() } + if Self.shouldBypassSystemKeychain() { + keyProvider = { nil } + resetProvider = {} + } else { + keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() } + resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() } + } } init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) { @@ -102,6 +107,20 @@ final class ClipboardEncryptionService { func resetStoredKey() { resetProvider() } + + static func shouldBypassSystemKeychain( + environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = ProcessInfo.processInfo.arguments + ) -> Bool { + if environment["CLIPBORED_DISABLE_KEYCHAIN"] == "1" || + environment["XCTestConfigurationFilePath"] != nil { + return true + } + + return arguments.contains { argument in + argument.contains(".xctest") || argument.hasSuffix("/xctest") + } + } } private enum ClipboardEncryptionKeychain { diff --git a/sources/clipbored/services/ClipboardMonitorService.swift b/sources/clipbored/services/ClipboardMonitorService.swift index 5f46028..ac2e6e2 100644 --- a/sources/clipbored/services/ClipboardMonitorService.swift +++ b/sources/clipbored/services/ClipboardMonitorService.swift @@ -15,6 +15,7 @@ final class ClipboardMonitorService { private var scheduledInterval: TimeInterval = 0 private var didReportReadFailure = false private(set) var isPaused = false + var onCapturedItem: (ClipboardItem) -> Void = { _ in } init( store: ClipboardStore, @@ -143,7 +144,9 @@ final class ClipboardMonitorService { if let item = readCurrentItem(from: pasteboard) { reportCaptured(item) DispatchQueue.main.async { [weak self] in - self?.store.upsert(item) + guard let self else { return } + let storedItem = self.store.upsert(item) + self.onCapturedItem(storedItem) } } else if !didReportReadFailure { reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.") @@ -431,10 +434,7 @@ final class ClipboardMonitorService { return nil } - let normalized = text - .split(whereSeparator: \.isWhitespace) - .joined(separator: " ") - return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength)) + return ImageTextExtractor.normalizedRecognizedText(text) } private func hasImage(on pasteboard: NSPasteboard) -> Bool { diff --git a/sources/clipbored/services/ClipboardStore.swift b/sources/clipbored/services/ClipboardStore.swift index 1d417af..dfe0a69 100644 --- a/sources/clipbored/services/ClipboardStore.swift +++ b/sources/clipbored/services/ClipboardStore.swift @@ -4,6 +4,22 @@ import Foundation import CommonCrypto import SQLite3 +struct ClipboardStoreRemoval { + let item: ClipboardItem + let index: Int +} + +enum ClipboardStoreArchiveError: LocalizedError { + case persistenceFailed + + var errorDescription: String? { + switch self { + case .persistenceFailed: + return "ClipBored could not save the imported archive." + } + } +} + final class ClipboardStore { private(set) var items: [ClipboardItem] = [] { didSet { notifyItemsChanged() } @@ -62,18 +78,17 @@ final class ClipboardStore { return base } - func upsert(_ incoming: ClipboardItem) { + @discardableResult + func upsert(_ incoming: ClipboardItem) -> ClipboardItem { guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else { - insertNewItem(incoming) - return + return insertNewItem(incoming) } if settings.keepFirstImage, incoming.kind == .image { - updateExistingKeepImage(incoming, at: index) - return + return updateExistingKeepImage(incoming, at: index) } - updateExistingItem(incoming, at: index) + return updateExistingItem(incoming, at: index) } func markUsed(_ id: UUID) { @@ -98,7 +113,11 @@ final class ClipboardStore { func setCollection(_ id: UUID, name: String?) { guard let index = items.firstIndex(where: { $0.id == id }) else { return } items[index].collectionName = ClipboardCollectionDefaults.normalizedName(name) - persistAsync(.upsert(items[index])) + let updated = items[index] + normalizeHistoryLength() + if items.contains(where: { $0.id == updated.id }) { + persistAsync(.upsert(updated)) + } } func setCustomTitle(_ id: UUID, title: String?) { @@ -123,21 +142,74 @@ final class ClipboardStore { return true } - func remove(_ id: UUID) { - guard let index = items.firstIndex(where: { $0.id == id }) else { return } + @discardableResult + func updateImage(_ id: UUID, imagePath: String, thumbnailPath: String, payloadHash: String) -> Bool { + guard let index = items.firstIndex(where: { $0.id == id }), + items[index].kind == .image, + !imagePath.clipboardTrimmed.isEmpty, + !thumbnailPath.clipboardTrimmed.isEmpty, + !payloadHash.clipboardTrimmed.isEmpty else { + return false + } + + items[index].payload = imagePath + items[index].imagePath = imagePath + items[index].thumbnailPath = thumbnailPath + items[index].payloadHash = payloadHash + persistAsync(.upsert(items[index]), purgeCache: true) + return true + } + + @discardableResult + func updateImageText(_ id: UUID, ocrText: String) -> Bool { + guard let normalizedText = ImageTextExtractor.normalizedRecognizedText(ocrText) else { + return false + } + guard let index = items.firstIndex(where: { $0.id == id }), + items[index].kind == .image else { + return false + } + + items[index].ocrText = normalizedText + persistAsync(.upsert(items[index])) + return true + } + + @discardableResult + func remove(_ id: UUID, purgeManagedCache: Bool = true) -> ClipboardStoreRemoval? { + guard let index = items.firstIndex(where: { $0.id == id }) else { return nil } let removed = items.remove(at: index) - if removed.kind.hasManagedCacheReference { - cacheService.removeCachedReferences(removed) + if purgeManagedCache { + purgeManagedCacheReferences(for: [removed]) } persistAsync(.delete(id)) + return ClipboardStoreRemoval(item: removed, index: index) + } + + func restore(_ removals: [ClipboardStoreRemoval]) { + guard !removals.isEmpty else { return } + + var restoredItems: [ClipboardItem] = [] + for removal in removals.sorted(by: { $0.index < $1.index }) { + guard !items.contains(where: { $0.id == removal.item.id }) else { continue } + let insertionIndex = max(0, min(removal.index, items.count)) + items.insert(removal.item, at: insertionIndex) + restoredItems.append(removal.item) + } + + guard !restoredItems.isEmpty else { return } + normalizeHistoryLength() + let retainedIDs = Set(items.map(\.id)) + let retainedRestoredItems = restoredItems.filter { retainedIDs.contains($0.id) } + persistAsync(.upsertMany(retainedRestoredItems)) + } + + func purgeManagedCacheReferences(for removals: [ClipboardStoreRemoval]) { + purgeManagedCacheReferences(for: removals.map(\.item)) } func removeAll() { - for item in items { - if item.kind.hasManagedCacheReference { - cacheService.removeCachedReferences(item) - } - } + purgeManagedCacheReferences(for: items) items.removeAll() persistAsync(.deleteAll) } @@ -155,27 +227,9 @@ final class ClipboardStore { } func normalizeHistoryLength() { - var pinnedCount = 0 - var unpinnedCount = 0 - var kept: [ClipboardItem] = [] - var overflow: [ClipboardItem] = [] - - kept.reserveCapacity(items.count) - for item in items { - if item.isPinned { - if pinnedCount < AppConfiguration.maxPinnedItems { - pinnedCount += 1 - kept.append(item) - } else { - overflow.append(item) - } - } else if unpinnedCount < settings.maxHistoryItems { - unpinnedCount += 1 - kept.append(item) - } else { - overflow.append(item) - } - } + let plan = retentionPlan(for: items) + let kept = plan.kept + let overflow = plan.overflow guard !overflow.isEmpty else { return } @@ -199,15 +253,80 @@ final class ClipboardStore { dataQueue.sync {} } - private func insertNewItem(_ incoming: ClipboardItem) { - items.insert(incoming, at: 0) - normalizeHistoryLength() - persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil) + @discardableResult + func exportArchive(to url: URL) throws -> ClipboardArchiveSummary { + dataQueue.sync {} + let collections = settings.customCollectionNames + .map { name in + ClipboardArchiveCollection( + name: name, + colorHex: settings.collectionColorHex(forCollectionNamed: name) + ) + } + return try ClipboardArchiveService().exportArchive( + items: items, + to: url, + cacheService: cacheService, + collections: collections + ) } - private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) { + @discardableResult + func exportCollection(named name: String, to url: URL) throws -> ClipboardArchiveSummary { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { + return try ClipboardArchiveService().exportArchive(items: [], to: url, cacheService: cacheService) + } + dataQueue.sync {} + let collectionItems = items.filter { + $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame + } + let collection = ClipboardArchiveCollection( + name: normalizedName, + colorHex: settings.collectionColorHex(forCollectionNamed: normalizedName) + ) + return try ClipboardArchiveService().exportArchive( + items: collectionItems, + to: url, + cacheService: cacheService, + collections: [collection] + ) + } + + @discardableResult + func importArchive(from url: URL) throws -> ClipboardArchiveSummary { + dataQueue.sync {} + let archiveImport = try ClipboardArchiveService().importArchive( + from: url, + cacheService: cacheService + ) + for collection in archiveImport.collections { + settings.ensureCollection(named: collection.name, colorHex: collection.colorHex) + } + for item in archiveImport.items { + if let collectionName = item.collectionName { + settings.ensureCollection(named: collectionName) + } + } + guard saveImportedItems(archiveImport.items) else { + throw ClipboardStoreArchiveError.persistenceFailed + } + return archiveImport.summary + } + + private func insertNewItem(_ incoming: ClipboardItem) -> ClipboardItem { + items.insert(incoming, at: 0) + normalizeHistoryLength() + if let retained = items.first(where: { $0.id == incoming.id }) { + persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil) + return retained + } + return incoming + } + + private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem { cacheService.removeCachedReferences(incoming) var existing = items.remove(at: index) + existing.createdAt = Date() existing.lastUsedAt = Date() existing.useCount += 1 if !incoming.displayText.isEmpty { @@ -215,15 +334,22 @@ final class ClipboardStore { } existing.sourceApp = incoming.sourceApp existing.sourceAppBundleId = incoming.sourceAppBundleId + existing.sourceDeviceName = incoming.sourceDeviceName + existing.collectionName = incoming.collectionName ?? existing.collectionName existing.customTitle = incoming.customTitle ?? existing.customTitle items.insert(existing, at: 0) normalizeHistoryLength() - persistAsync(.upsert(existing), purgeCache: existing.kind == .image) + if let retained = items.first(where: { $0.id == existing.id }) { + persistAsync(.upsert(retained), purgeCache: retained.kind == .image) + return retained + } + return existing } - private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) { + private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem { var existing = items.remove(at: index) let previousCachedItem = existing + existing.createdAt = Date() existing.lastUsedAt = Date() existing.useCount += 1 if !incoming.displayText.isEmpty { @@ -234,6 +360,8 @@ final class ClipboardStore { existing.kind = incoming.kind existing.sourceApp = incoming.sourceApp existing.sourceAppBundleId = incoming.sourceAppBundleId + existing.sourceDeviceName = incoming.sourceDeviceName + existing.collectionName = incoming.collectionName ?? existing.collectionName existing.customTitle = incoming.customTitle ?? existing.customTitle if incoming.kind == .image || incoming.kind == .url { @@ -252,7 +380,94 @@ final class ClipboardStore { items.insert(existing, at: 0) normalizeHistoryLength() - persistAsync(.upsert(existing), purgeCache: existing.imagePath != nil) + if let retained = items.first(where: { $0.id == existing.id }) { + persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil) + return retained + } + return existing + } + + private func purgeManagedCacheReferences(for items: [ClipboardItem]) { + for item in items where item.kind.hasManagedCacheReference { + cacheService.removeCachedReferences(item) + } + } + + private func saveImportedItems(_ importedItems: [ClipboardItem]) -> Bool { + guard !importedItems.isEmpty else { return true } + + var mergedByID: [UUID: ClipboardItem] = [:] + mergedByID.reserveCapacity(items.count + importedItems.count) + for item in items { + mergedByID[item.id] = item + } + for item in importedItems { + mergedByID[item.id] = item + } + + let merged = mergedByID.values.sorted(by: historySort) + let plan = retentionPlan(for: merged) + guard saveAll(plan.kept) else { return false } + + items = plan.kept + if !plan.overflow.isEmpty { + purgeManagedCacheReferences(for: plan.overflow) + cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes) + } + hardenStoragePermissions() + return true + } + + private func retentionPlan(for sourceItems: [ClipboardItem]) -> (kept: [ClipboardItem], overflow: [ClipboardItem]) { + var pinnedCount = 0 + var unpinnedCount = 0 + var kept: [ClipboardItem] = [] + var overflow: [ClipboardItem] = [] + let retentionCutoff = settings.historyRetention.cutoffDate() + + kept.reserveCapacity(sourceItems.count) + for item in sourceItems { + if item.isPinned { + if pinnedCount < AppConfiguration.maxPinnedItems { + pinnedCount += 1 + kept.append(item) + } else if isCollectionRetained(item) { + kept.append(item) + } else { + overflow.append(item) + } + } else if isCollectionRetained(item) { + kept.append(item) + } else if isExpiredByRetention(item, cutoff: retentionCutoff) { + overflow.append(item) + } else if unpinnedCount < settings.maxHistoryItems { + unpinnedCount += 1 + kept.append(item) + } else { + overflow.append(item) + } + } + + return (kept, overflow) + } + + private func historySort(_ lhs: ClipboardItem, _ rhs: ClipboardItem) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + if lhs.lastUsedAt != rhs.lastUsedAt { + return lhs.lastUsedAt > rhs.lastUsedAt + } + return lhs.id.uuidString < rhs.id.uuidString + } + + private func isCollectionRetained(_ item: ClipboardItem) -> Bool { + ClipboardCollectionDefaults.normalizedName(item.collectionName) != nil + } + + private func isExpiredByRetention(_ item: ClipboardItem, cutoff: Date?) -> Bool { + guard let cutoff else { return false } + return item.createdAt < cutoff } private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) { @@ -341,7 +556,8 @@ final class ClipboardStore { is_pinned INTEGER NOT NULL DEFAULT 0, ocr_text TEXT, collection_name TEXT, - custom_title TEXT + custom_title TEXT, + source_device_name TEXT ); """ @@ -357,6 +573,7 @@ final class ClipboardStore { _ = execute(createTable) _ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;") _ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;") + _ = execute("ALTER TABLE clipboard_items ADD COLUMN source_device_name TEXT;") _ = execute(createIndexes) } @@ -422,7 +639,8 @@ final class ClipboardStore { sourceAppBundleId: row["sourceAppBundleId"] as? String, ocrText: row["ocrText"] as? String, collectionName: row["collectionName"] as? String, - customTitle: row["customTitle"] as? String + customTitle: row["customTitle"] as? String, + sourceDeviceName: row["sourceDeviceName"] as? String ?? ClipboardItem.localDeviceName ) } @@ -535,7 +753,7 @@ final class ClipboardStore { id, kind, display_text, payload, payload_hash, created_at, last_used_at, use_count, source_app, source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text, collection_name, - custom_title + custom_title, source_device_name FROM clipboard_items ORDER BY created_at DESC, last_used_at DESC """ @@ -617,6 +835,7 @@ final class ClipboardStore { let ocrTextValue = stringValue(13) let collectionNameValue = stringValue(14) let customTitleValue = stringValue(15) + let sourceDeviceNameValue = stringValue(16) needsEncryptionMigration = needsEncryptionMigration || sourceAppValue.migrationNeeded @@ -626,6 +845,7 @@ final class ClipboardStore { || ocrTextValue.migrationNeeded || collectionNameValue.migrationNeeded || customTitleValue.migrationNeeded + || sourceDeviceNameValue.migrationNeeded hadDecodeFailure = hadDecodeFailure || sourceAppValue.decodeFailed || sourceAppBundleIdValue.decodeFailed @@ -634,6 +854,7 @@ final class ClipboardStore { || ocrTextValue.decodeFailed || collectionNameValue.decodeFailed || customTitleValue.decodeFailed + || sourceDeviceNameValue.decodeFailed loaded.append( ClipboardItem( @@ -652,7 +873,8 @@ final class ClipboardStore { sourceAppBundleId: sourceAppBundleIdValue.value, ocrText: ocrTextValue.value, collectionName: collectionNameValue.value, - customTitle: customTitleValue.value + customTitle: customTitleValue.value, + sourceDeviceName: sourceDeviceNameValue.value ?? ClipboardItem.localDeviceName ) ) } @@ -671,6 +893,7 @@ final class ClipboardStore { private enum PersistenceMutation { case upsert(ClipboardItem) + case upsertMany([ClipboardItem]) case delete(UUID) case deleteMany([UUID]) case deleteAll @@ -684,8 +907,8 @@ final class ClipboardStore { id, kind, display_text, payload, payload_hash, created_at, last_used_at, use_count, source_app, source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text, - collection_name, custom_title - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + collection_name, custom_title, source_device_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """ switch mutation { @@ -719,6 +942,40 @@ final class ClipboardStore { shouldRollback = true } + case .upsertMany(let items): + guard !items.isEmpty else { return } + var statement: OpaquePointer? + var shouldRollback = false + defer { + if let statement { + sqlite3_finalize(statement) + } + if shouldRollback { + _ = execute("ROLLBACK;") + } + } + + guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return } + guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else { + shouldRollback = true + return + } + + for item in items { + bindItem(item, to: statement) + let stepResult = sqlite3_step(statement) + if stepResult != SQLITE_DONE { + shouldRollback = true + return + } + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + } + + if !execute("COMMIT;") { + shouldRollback = true + } + case .delete(let id): guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return } let query = "DELETE FROM clipboard_items WHERE id = ?;" @@ -784,8 +1041,8 @@ final class ClipboardStore { id, kind, display_text, payload, payload_hash, created_at, last_used_at, use_count, source_app, source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text, - collection_name, custom_title - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + collection_name, custom_title, source_device_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """ guard execute("BEGIN IMMEDIATE TRANSACTION;") else { @@ -870,5 +1127,6 @@ final class ClipboardStore { bindText(statement, 14, encryptionService.protect(item.ocrText)) bindText(statement, 15, encryptionService.protect(item.collectionName)) bindText(statement, 16, encryptionService.protect(item.customTitle)) + bindText(statement, 17, encryptionService.protect(item.sourceDeviceName)) } } diff --git a/sources/clipbored/services/ImageTextExtractor.swift b/sources/clipbored/services/ImageTextExtractor.swift index 0a7500c..75e7676 100644 --- a/sources/clipbored/services/ImageTextExtractor.swift +++ b/sources/clipbored/services/ImageTextExtractor.swift @@ -29,12 +29,20 @@ enum ImageTextExtractor { return normalized(lines) } - private static func normalized(_ lines: [String]) -> String? { - let text = lines - .filter { !$0.isEmpty } - .joined(separator: " ") + static func normalizedRecognizedText(_ text: String?) -> String? { + guard let text else { return nil } + let normalized = text .split(whereSeparator: \.isWhitespace) .joined(separator: " ") - return text.isEmpty ? nil : text + guard !normalized.isEmpty else { return nil } + return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength)) + } + + private static func normalized(_ lines: [String]) -> String? { + normalizedRecognizedText( + lines + .filter { !$0.isEmpty } + .joined(separator: " ") + ) } } diff --git a/sources/clipbored/services/PasteActionService.swift b/sources/clipbored/services/PasteActionService.swift index 1199d07..55f918b 100644 --- a/sources/clipbored/services/PasteActionService.swift +++ b/sources/clipbored/services/PasteActionService.swift @@ -57,6 +57,18 @@ final class PasteActionService { return .failed("Could not write item to clipboard.") } + return completePaste(targetApp: targetApp) + } + + func paste(_ items: [ClipboardItem], targetApp: NSRunningApplication?) -> PasteActionResult { + guard writeToPasteboard(items) else { + return .failed("Could not write items to clipboard.") + } + + return completePaste(targetApp: targetApp) + } + + private func completePaste(targetApp: NSRunningApplication?) -> PasteActionResult { guard let targetApp, !targetApp.isTerminated else { return .copied @@ -97,6 +109,11 @@ final class PasteActionService { writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.") } + @discardableResult + func copy(_ items: [ClipboardItem]) -> PasteActionResult { + writeToPasteboard(items) ? .copied : .failed("Could not write items to clipboard.") + } + @discardableResult func copyPlainText(_ item: ClipboardItem) -> PasteActionResult { writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.") @@ -242,6 +259,26 @@ final class PasteActionService { return didWrite } + @discardableResult + func writeToPasteboard(_ items: [ClipboardItem]) -> Bool { + guard !items.isEmpty else { return false } + var writers: [NSPasteboardWriting] = [] + for item in items { + let itemWriters = pasteboardWriters(for: item) + guard !itemWriters.isEmpty else { return false } + writers.append(contentsOf: itemWriters) + } + guard !writers.isEmpty else { return false } + + let board = NSPasteboard.general + board.clearContents() + let didWrite = board.writeObjects(writers) + if didWrite { + ClipboardSelfWriteTracker.mark(changeCount: board.changeCount) + } + return didWrite + } + func plainText(for item: ClipboardItem) -> String? { switch item.kind { case .text, .code, .unknown: diff --git a/sources/clipbored/services/ShortcutManager.swift b/sources/clipbored/services/ShortcutManager.swift index 902633f..5ec13a2 100644 --- a/sources/clipbored/services/ShortcutManager.swift +++ b/sources/clipbored/services/ShortcutManager.swift @@ -25,27 +25,32 @@ final class ShortcutManager { private enum HotKeyID: UInt32 { case openPanel = 1 case openSettings = 2 + case stackCapture = 3 } private let onOpenClipboardPanel: () -> Void private let onOpenSettings: () -> Void + private let onToggleStackCapture: () -> Void private let onStatusChange: (RegistrationStatus) -> Void private var openBinding: ShortcutBinding private var settingsBinding: ShortcutBinding private var openHotKey: EventHotKeyRef? private var settingsHotKey: EventHotKeyRef? + private var stackCaptureHotKey: EventHotKeyRef? private var eventHandler: EventHandlerRef? init( onOpenClipboardPanel: @escaping () -> Void, onOpenSettings: @escaping () -> Void, + onToggleStackCapture: @escaping () -> Void = {}, onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in }, openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding ) { self.onOpenClipboardPanel = onOpenClipboardPanel self.onOpenSettings = onOpenSettings + self.onToggleStackCapture = onToggleStackCapture self.onStatusChange = onStatusChange self.openBinding = openShortcut self.settingsBinding = settingsShortcut @@ -59,7 +64,7 @@ final class ShortcutManager { func start() -> RegistrationStatus { stop() - if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) { + if let status = Self.validationFailure(for: openBinding) ?? Self.validationFailure(for: settingsBinding) { onStatusChange(status) return status } @@ -68,6 +73,11 @@ final class ShortcutManager { onStatusChange(status) return status } + if openBinding == Self.stackCaptureShortcut || settingsBinding == Self.stackCaptureShortcut { + let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText) + onStatusChange(status) + return status + } var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) let installStatus = InstallEventHandler( @@ -104,6 +114,13 @@ final class ShortcutManager { return settingsStatus } + let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey) + guard stackCaptureStatus == .registered else { + stop() + onStatusChange(stackCaptureStatus) + return stackCaptureStatus + } + onStatusChange(.registered) return .registered } @@ -122,12 +139,16 @@ final class ShortcutManager { if let settingsHotKey { UnregisterEventHotKey(settingsHotKey) } + if let stackCaptureHotKey { + UnregisterEventHotKey(stackCaptureHotKey) + } if let eventHandler { RemoveEventHandler(eventHandler) } openHotKey = nil settingsHotKey = nil + stackCaptureHotKey = nil eventHandler = nil } @@ -157,7 +178,7 @@ final class ShortcutManager { return .registrationFailed(osStatusMessage(status)) } - private func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? { + static func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? { guard Self.virtualKeyCode(for: binding.key) != nil else { return .unsupportedShortcut(binding.displayText) } @@ -187,6 +208,8 @@ final class ShortcutManager { onOpenClipboardPanel() case .openSettings: onOpenSettings() + case .stackCapture: + onToggleStackCapture() case nil: break } @@ -260,6 +283,11 @@ final class ShortcutManager { private static let hotKeySignature: OSType = 0x436C7042 + static let stackCaptureShortcut = ShortcutBinding( + key: "c", + modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue + ) + private func osStatusMessage(_ status: OSStatus) -> String { "OSStatus \(status)" } diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 71633c1..9461e4d 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -16,14 +16,22 @@ struct ClipboardPanelReflowPlan { enum ClipboardPanelShortcutAction: Equatable { case copy case copyPlainText + case edit + case focusSearch case newCollection + case nextCollection case open case pastePlainText case pasteStackNext case preview + case previousCollection + case rename case reveal case showInClipboard + case toggleCapturePause case toggleStack + case toggleStackCapture + case undoDelete } enum ClipboardPanelNavigationAction: Equatable { @@ -35,23 +43,41 @@ enum ClipboardPanelNavigationAction: Equatable { case previous } +enum ClipboardPanelSelectionAction: Equatable { + case extendFirst + case extendLast + case extendNext + case extendPageNext + case extendPagePrevious + case extendPrevious + case selectAll +} + final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate { private enum Animation { - static let showDuration: TimeInterval = 0.16 - static let hideDuration: TimeInterval = 0.12 - static let reflowDuration: TimeInterval = 0.10 + static let showDuration: TimeInterval = 0.22 + static let hideDuration: TimeInterval = 0.16 + static let reflowDuration: TimeInterval = 0.18 static let easing: CAMediaTimingFunctionName = .easeInEaseOut } private enum Metrics { - static let shelfHeightRatio: CGFloat = 0.42 - static let minimumShelfHeight: CGFloat = 408 - static let maximumShelfHeight: CGFloat = 430 + static let shelfHeightRatio: CGFloat = 0.18 + static let minimumShelfHeight: CGFloat = 156 + static let maximumShelfHeight: CGFloat = 176 + static let minimumUserShelfHeight: CGFloat = 150 + static let maximumUserShelfHeight: CGFloat = 680 + static let maximumUserShelfHeightRatio: CGFloat = 0.72 + static let preferredVerticalShelfWidth: CGFloat = 336 + static let minimumVerticalShelfWidth: CGFloat = 320 + static let maximumVerticalShelfWidthRatio: CGFloat = 0.30 static let minimumBottomInset: CGFloat = 18 static let maximumBottomInset: CGFloat = 20 + static let hiddenDockRevealInsetLimit: CGFloat = 8 } private var panel: NSPanel! private var panelView: ClipboardPanelView! + private let settings: SettingsModel private(set) var isVisible = false private var clickMonitor: Any? private var keyMonitor: Any? @@ -62,6 +88,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel private let openSettings: () -> Void private var isAnimating = false private var quickLookURL: URL? + private var linkPreviewController: LinkPreviewWindowController? private var screenParametersObserver: NSObjectProtocol? private static let quickPasteKeyCodes: [UInt16: Int] = [ 18: 0, @@ -97,6 +124,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel pollClipboardNow: @escaping () -> Void = {}, openSettings: @escaping () -> Void = {} ) { + self.settings = settings self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) self.pollClipboardNow = pollClipboardNow self.openSettings = openSettings @@ -117,7 +145,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel onPreview: { [weak self] in self?.previewSelected() } ) - let contentSize = NSSize(width: 1200, height: 420) + let contentSize = NSSize(width: 336, height: 760) panel = KeyablePanel( contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height), styleMask: [.nonactivatingPanel, .fullSizeContentView], @@ -132,13 +160,25 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel panel.delegate = self panel.isOpaque = false panel.backgroundColor = NSColor.clear - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.collectionBehavior = Self.panelCollectionBehavior panel.becomesKeyOnlyIfNeeded = false panel.titlebarAppearsTransparent = true panel.titleVisibility = .hidden panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true panel.standardWindowButton(.closeButton)?.isHidden = true + applyPanelSharingSetting() + + settings.observe { [weak self] change in + guard change == .hideFromScreenCapture || change == .panelLayout else { return } + DispatchQueue.main.async { + if change == .hideFromScreenCapture { + self?.applyPanelSharingSetting() + } else { + self?.reflowPanelForScreenChange() + } + } + } screenParametersObserver = NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, @@ -157,15 +197,37 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } } - func toggle() { + func toggle(preferredScreen explicitScreen: NSScreen? = nil) { if isVisible { hide() } else { - show() + show(preferredScreen: explicitScreen) } } - func show() { + func createTextClip() { + performWhenVisible { [weak self] in + guard let self else { return } + self.panelView.createTextClip() + } + } + + func createCollection() { + performWhenVisible { [weak self] in + guard let self else { return } + self.panelView.createCollection() + } + } + + func toggleStackCaptureMode() { + viewModel.toggleStackCaptureMode() + } + + func addCapturedItemToStack(_ item: ClipboardItem) { + viewModel.addCapturedItemToStack(item) + } + + func show(preferredScreen explicitScreen: NSScreen? = nil) { if isVisible || isAnimating { return } isAnimating = true isVisible = true @@ -173,7 +235,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel rememberTargetApplication() pollClipboardNow() - guard let screen = preferredScreen() else { + guard let screen = preferredScreen(explicitScreen: explicitScreen) else { isVisible = false isAnimating = false return @@ -181,7 +243,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel activeScreenSnapshot = (screen.frame, screen.visibleFrame) let frames = Self.panelFrames( forScreenFrame: screen.frame, - visibleFrame: screen.visibleFrame + visibleFrame: screen.visibleFrame, + side: settings.panelSide ) panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame)) panel.setFrame(frames.hidden, display: false) @@ -204,7 +267,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel self.panelView.finishOpeningTransition() guard self.isVisible else { return } self.installClickMonitor() - self.panelView.focusSearchField() + self.panelView.focusSelectedCardForKeyboardNavigation() } installKeyMonitor() @@ -230,7 +293,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel let screenFrames = activeScreenSnapshot ?? activeScreenFrames() let hidden = Self.panelFrames( forScreenFrame: screenFrames.screenFrame, - visibleFrame: screenFrames.visibleFrame + visibleFrame: screenFrames.visibleFrame, + side: settings.panelSide ).hidden isAnimating = true @@ -252,62 +316,120 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } } + private func performWhenVisible(_ action: @escaping () -> Void) { + if isVisible, !isAnimating { + action() + return + } + + show() + DispatchQueue.main.asyncAfter(deadline: .now() + Animation.showDuration + 0.03) { [weak self] in + guard let self, self.isVisible else { return } + action() + } + } + func windowDidResignKey(_ notification: Notification) { hide() } func windowDidBecomeKey(_ notification: Notification) { - panelView.focusSearchField() + panelView.focusSelectedCardForKeyboardNavigation() } - private func preferredScreen() -> NSScreen? { - if let menuBarScreen = preferredScreenProvider() { - return menuBarScreen - } - + private func preferredScreen(explicitScreen: NSScreen? = nil) -> NSScreen? { let point = NSEvent.mouseLocation - return NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.screens.first + let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } + return Self.selectedOpenScreen( + explicit: explicitScreen, + preferred: preferredScreenProvider(), + pointer: pointerScreen, + fallback: NSScreen.screens.first + ) + } + + static func selectedOpenScreen( + explicit: Screen?, + preferred: Screen?, + pointer: Screen?, + fallback: Screen? + ) -> Screen? { + explicit ?? preferred ?? pointer ?? fallback + } + + static func selectedReflowScreen( + currentPanel: Screen?, + lastKnown: Screen?, + preferred: Screen?, + pointer: Screen?, + fallback: Screen? + ) -> Screen? { + 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) -> (shown: NSRect, hidden: NSRect) { + static func panelFrames( + forScreenFrame screenFrame: CGRect, + visibleFrame: CGRect, + preferredHeight _: CGFloat? = nil, + side: ClipboardPanelSide = .right + ) -> (shown: NSRect, hidden: NSRect) { let intersectedFrame = visibleFrame.intersection(screenFrame) let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame - let frameHeight = effectiveFrame.height > 0 ? effectiveFrame.height : max(1, screenFrame.height) - let height = panelHeight(within: frameHeight) - let targetWidth = max(1, floor(effectiveFrame.width)) - let shownMinX = effectiveFrame.minX - let shownMinY = max(screenFrame.minY, visibleFrame.minY) + return verticalPanelFrames(forScreenFrame: screenFrame, effectiveFrame: effectiveFrame, side: side) + } + + private static func verticalPanelFrames( + forScreenFrame screenFrame: CGRect, + effectiveFrame: CGRect, + side: ClipboardPanelSide + ) -> (shown: NSRect, hidden: NSRect) { + let targetWidth = panelWidth(within: max(1, effectiveFrame.width)) + let targetHeight = max(1, floor(effectiveFrame.maxY - screenFrame.minY)) + let shownX: CGFloat + let hiddenX: CGFloat + switch side { + case .left: + shownX = effectiveFrame.minX + hiddenX = shownX - targetWidth - 1 + case .right: + shownX = effectiveFrame.maxX - targetWidth + hiddenX = effectiveFrame.maxX + 1 + } let shown = NSRect( - x: shownMinX, - y: shownMinY, + x: shownX, + y: screenFrame.minY, width: targetWidth, - height: height + height: targetHeight ) let hidden = NSRect( - x: shown.minX, - y: shown.minY - height - 1, + x: hiddenX, + y: shown.minY, width: shown.width, - height: height + height: targetHeight ) return (shown, hidden) } - private static func panelHeight(within visibleHeight: CGFloat) -> CGFloat { - let available = max(1, visibleHeight) - let preferred = floor(available * Metrics.shelfHeightRatio) - let clamped = min(max(preferred, Metrics.minimumShelfHeight), Metrics.maximumShelfHeight) - return min(available, clamped) + private static func panelWidth(within visibleWidth: CGFloat) -> CGFloat { + let available = max(1, visibleWidth) + let preferred = min(Metrics.preferredVerticalShelfWidth, floor(available * Metrics.maximumVerticalShelfWidthRatio)) + return min(available, max(Metrics.minimumVerticalShelfWidth, preferred)) } static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat { - let dockInset = max(0, visibleFrame.minY - screenFrame.minY) + let dockInset = visibleBottomDockInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2)) } + private static func visibleBottomDockInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat { + let inset = max(0, visibleFrame.minY - screenFrame.minY) + return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0 + } + static var animationProfile: ClipboardPanelAnimationProfile { ClipboardPanelAnimationProfile( showDuration: Animation.showDuration, @@ -317,6 +439,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel ) } + 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( @@ -325,6 +451,31 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel ) } + static func reflowPlan( + forScreenFrame screenFrame: CGRect, + visibleFrame: CGRect, + preferredHeight _: CGFloat? = nil, + side: ClipboardPanelSide = .right + ) -> ClipboardPanelReflowPlan { + let frames = panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + side: side + ) + return ClipboardPanelReflowPlan( + frame: frames.shown, + bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame) + ) + } + + static func panelSharingType(hideFromScreenCapture: Bool) -> NSWindow.SharingType { + hideFromScreenCapture ? .none : .readOnly + } + + private func applyPanelSharingSetting() { + panel.sharingType = Self.panelSharingType(hideFromScreenCapture: settings.hideFromScreenCapture) + } + private func rememberTargetApplication() { guard let frontmost = NSWorkspace.shared.frontmostApplication else { targetApplication = nil @@ -385,6 +536,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel self.performShortcutAction(action) return nil } + if self.shouldHandlePanelKeyEvent(event), + let action = Self.selectionShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { + self.performSelectionAction(action) + return nil + } guard self.shouldHandlePanelKeyEvent(event) else { return event } if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { self.performNavigationAction(action) @@ -432,14 +588,47 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel panelView.focusSelectedCardForKeyboardNavigation() } + private func performSelectionAction(_ action: ClipboardPanelSelectionAction) { + switch action { + case .extendFirst: + viewModel.selectItem(at: 0, mode: .range) + case .extendLast: + viewModel.selectItem(at: viewModel.visibleItems.count - 1, mode: .range) + case .extendNext: + extendSelection(by: 1) + case .extendPageNext: + extendSelection(by: panelView.visibleCardPageStep) + case .extendPagePrevious: + extendSelection(by: -panelView.visibleCardPageStep) + case .extendPrevious: + extendSelection(by: -1) + case .selectAll: + viewModel.selectAllVisibleItems() + } + panelView.focusSelectedCardForKeyboardNavigation() + } + + private func extendSelection(by delta: Int) { + let count = viewModel.visibleItems.count + guard count > 0 else { return } + let target = max(0, min(count - 1, viewModel.selectedIndex + delta)) + viewModel.selectItem(at: target, mode: .range) + } + private func performShortcutAction(_ action: ClipboardPanelShortcutAction) { switch action { case .copy: viewModel.copySelected() case .copyPlainText: viewModel.copySelectedPlainText() + case .edit: + panelView.editSelectedClip() + case .focusSearch: + panelView.focusSearchOrShowFilters() case .newCollection: panelView.createCollection() + case .nextCollection: + viewModel.selectAdjacentCollection(delta: 1) case .open: viewModel.openSelected() case .pastePlainText: @@ -448,17 +637,46 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel viewModel.pasteNextStackItem() case .preview: previewSelected() + case .previousCollection: + viewModel.selectAdjacentCollection(delta: -1) + case .rename: + panelView.renameSelectedClip() case .reveal: viewModel.revealSelected() case .showInClipboard: panelView.showSelectedInClipboard() + case .toggleCapturePause: + toggleCapturePauseFromShortcut() case .toggleStack: viewModel.toggleSelectedStackMembership() + case .toggleStackCapture: + viewModel.toggleStackCaptureMode() + case .undoDelete: + viewModel.undoLastDelete() + } + } + + private func toggleCapturePauseFromShortcut() { + settings.pauseCaptureUntil = nil + if settings.pauseCapture { + settings.pauseCapture = false + settings.setCaptureStatus(message: "Capture resumed.") + } else { + settings.pauseCapture = true + settings.setCaptureStatus(message: "Capture is paused.") } } private func previewSelected() { + if let request = viewModel.linkPreviewRequestForSelected() { + quickLookURL = nil + QLPreviewPanel.shared()?.orderOut(nil) + showLinkPreview(request) + return + } + guard let url = viewModel.previewURLForSelected() else { return } + linkPreviewController?.close() quickLookURL = url guard let previewPanel = QLPreviewPanel.shared() else { NSWorkspace.shared.open(url) @@ -470,6 +688,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel previewPanel.makeKeyAndOrderFront(nil) } + private func showLinkPreview(_ request: LinkPreviewRequest) { + let controller = linkPreviewController ?? LinkPreviewWindowController() + linkPreviewController = controller + controller.show(request, relativeTo: panel) + } + private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool { shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false) } @@ -498,6 +722,13 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + if relevantModifiers == .command { + switch keyCode { + case 126: return .first + case 125: return .last + default: return nil + } + } guard relevantModifiers.isEmpty else { return nil } switch keyCode { case 115: return .first @@ -510,6 +741,23 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } } + static func selectionShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelSelectionAction? { + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + if relevantModifiers == .command { + return keyCode == 0 ? .selectAll : nil + } + guard relevantModifiers == .shift else { return nil } + switch keyCode { + case 115: return .extendFirst + case 119: return .extendLast + case 124: return .extendNext + case 121: return .extendPageNext + case 116: return .extendPagePrevious + case 123: return .extendPrevious + default: return nil + } + } + static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) guard relevantModifiers == [.command, .shift] else { return nil } @@ -533,6 +781,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel switch keyCode { case 8: return .copy + case 3: + return .focusSearch + case 14: + return .edit case 31: return .open case 5: @@ -540,7 +792,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel case 16: return .preview case 15: - return .reveal + return .rename + case 17: + return .toggleCapturePause + case 6: + return .undoDelete + case 123: + return .previousCollection + case 124: + return .nextCollection default: return nil } @@ -548,12 +808,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + if relevantModifiers == .shift { + return keyCode == 36 ? .pastePlainText : nil + } guard relevantModifiers == [.command, .shift] else { return nil } switch keyCode { case 1: return .toggleStack case 8: - return .copyPlainText + return .toggleStackCapture case 45: return .newCollection case 9: @@ -585,6 +848,34 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel 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() { @@ -613,10 +904,22 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel private func reflowPanelForScreenChange() { guard isVisible else { return } guard !isAnimating else { return } - guard let screen = preferredScreen() ?? panel.screen ?? NSScreen.screens.first else { return } + let point = NSEvent.mouseLocation + let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } + guard let screen = Self.selectedReflowScreen( + currentPanel: panel.screen, + lastKnown: screen(matchingFrame: activeScreenSnapshot?.screenFrame), + preferred: preferredScreenProvider(), + pointer: pointerScreen, + fallback: NSScreen.screens.first + ) else { return } activeScreenSnapshot = (screen.frame, screen.visibleFrame) - let plan = Self.reflowPlan(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame) + let plan = Self.reflowPlan( + forScreenFrame: screen.frame, + visibleFrame: screen.visibleFrame, + side: settings.panelSide + ) panelView.setBottomSafeInset(plan.bottomSafeInset) isAnimating = true @@ -647,6 +950,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel let fallback = preferredScreen() ?? NSScreen.screens.first return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero) } + + private func screen(matchingFrame frame: CGRect?) -> NSScreen? { + guard let frame else { return nil } + return NSScreen.screens.first { $0.frame == frame } + } } private final class KeyablePanel: NSPanel { diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 3e23d2a..32c2df1 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -10,6 +10,7 @@ private struct ClipboardItemCardLayout: Equatable { let actionButtonSize: CGFloat let primaryActionButtonSize: CGFloat let actionRailHeight: CGFloat + let activeLift: CGFloat static let regular = ClipboardItemCardLayout( width: 320, @@ -20,7 +21,8 @@ private struct ClipboardItemCardLayout: Equatable { footerHeight: 36, actionButtonSize: 24, primaryActionButtonSize: 30, - actionRailHeight: 34 + actionRailHeight: 34, + activeLift: 0 ) static let compact = ClipboardItemCardLayout( @@ -32,11 +34,44 @@ private struct ClipboardItemCardLayout: Equatable { footerHeight: 32, actionButtonSize: 22, primaryActionButtonSize: 28, - actionRailHeight: 32 + actionRailHeight: 32, + activeLift: 0 + ) + + static let expanded = ClipboardItemCardLayout( + width: 380, + height: 320, + inset: 18, + headerHeight: 58, + bodyHeight: 218, + footerHeight: 44, + actionButtonSize: 26, + primaryActionButtonSize: 32, + actionRailHeight: 36, + activeLift: 0 ) var isCompact: Bool { - self == Self.compact + height == Self.compact.height + && inset == Self.compact.inset + && headerHeight == Self.compact.headerHeight + && bodyHeight == Self.compact.bodyHeight + && footerHeight == Self.compact.footerHeight + } + + func withWidth(_ width: CGFloat) -> ClipboardItemCardLayout { + ClipboardItemCardLayout( + width: width, + height: height, + inset: inset, + headerHeight: headerHeight, + bodyHeight: bodyHeight, + footerHeight: footerHeight, + actionButtonSize: actionButtonSize, + primaryActionButtonSize: primaryActionButtonSize, + actionRailHeight: actionRailHeight, + activeLift: activeLift + ) } } @@ -133,33 +168,185 @@ private struct CollectionCreationRequest { let colorHex: String } +private struct ClipboardItemCardReuseFingerprint: Equatable { + let id: UUID + let kind: ClipboardItemKind + let displayText: String + let payload: String + let payloadHash: String + let createdAt: Date + let lastUsedAt: Date + let useCount: Int + let sourceApp: String? + let imagePath: String? + let thumbnailPath: String? + let isPinned: Bool + let sourceAppBundleID: String? + let ocrText: String? + let collectionName: String? + let customTitle: String? + let sourceDeviceName: String? + let layout: ClipboardItemCardLayout + let collectionNames: [String] + let canShowInClipboard: Bool + let activeCollectionName: String? + let activeCollectionColorHex: String? + let quickPasteNumber: Int? +} + +#if DEBUG +private struct ClipboardSearchFilterPart { + let raw: String + let canonical: String + let canonicalKey: String? + let keyValueCanonical: String? + let isExact: Bool +} +#endif + +private final class ClipboardPanelSearchField: NSSearchField { + var onFocusStateChanged: (() -> Void)? + private(set) var hasKeyboardFocusForPresentation = false + + override func becomeFirstResponder() -> Bool { + let becameFirstResponder = super.becomeFirstResponder() + if becameFirstResponder { + setKeyboardFocusForPresentation(true) + } + return becameFirstResponder + } + + override func resignFirstResponder() -> Bool { + let resignedFirstResponder = super.resignFirstResponder() + if resignedFirstResponder { + setKeyboardFocusForPresentation(false) + } + return resignedFirstResponder + } + + func setKeyboardFocusForPresentation(_ focused: Bool) { + guard hasKeyboardFocusForPresentation != focused else { + onFocusStateChanged?() + return + } + hasKeyboardFocusForPresentation = focused + onFocusStateChanged?() + } +} + +private enum ClipboardCardPresentation: Equatable { + case expanded + case horizontalIcon + case horizontalFocus + case verticalRow + case verticalFocus + + var isExpanded: Bool { + self == .expanded || self == .verticalFocus + } + + var showsFloatingActions: Bool { + self == .expanded + } + + func size(for layout: ClipboardItemCardLayout) -> NSSize { + switch self { + case .expanded: + return NSSize(width: layout.width, height: layout.height + layout.activeLift) + case .horizontalIcon: + let side: CGFloat = layout.isCompact ? 56 : 64 + return NSSize(width: side, height: side) + case .horizontalFocus: + let width: CGFloat = layout.isCompact ? 224 : 252 + let height: CGFloat = layout.isCompact ? 56 : 64 + return NSSize(width: width, height: height) + case .verticalRow: + let height: CGFloat = layout.isCompact ? 56 : 64 + return NSSize(width: layout.width, height: height) + case .verticalFocus: + return NSSize(width: layout.width, height: layout.height + layout.activeLift) + } + } +} + final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private enum Metrics { - static let actionButtonSize: CGFloat = 30 - static let panelTopInset: CGFloat = 12 - static let panelSideInset: CGFloat = 22 + static let actionButtonSize: CGFloat = 28 + static let searchControlSize: CGFloat = 30 + static let panelTopInset: CGFloat = 6 + static let panelSideInset: CGFloat = 12 static let actionBarHorizontalPadding: CGFloat = 10 static let panelStatusBarHeight: CGFloat = 24 static let minimumBottomInset: CGFloat = 20 - static let panelCornerRadius: CGFloat = 0 + static let panelCornerRadius: CGFloat = 22 static let compactCardThreshold: CGFloat = 760 static let emptyStateMinimumWidth: CGFloat = 760 - static let compactSearchWidth: CGFloat = 34 + static let collectionRailHeight: CGFloat = 32 + static let collectionRailMaximumWidth: CGFloat = 760 + static let verticalCollectionRailMaximumWidth: CGFloat = 260 + static let collectionSideRailWidth: CGFloat = 36 + static let collectionSideRailItemSize: CGFloat = 34 + static let collectionSideRailGap: CGFloat = 4 + static let collectionRailCardClearance: CGFloat = 8 + static let minimumVerticalCardWidth: CGFloat = 180 static let expandedSearchWidth: CGFloat = 300 + static let verticalExpandedSearchWidth: CGFloat = 164 + static let expandedCardHeightThreshold: CGFloat = 560 + static let expandedCardWidthThreshold: CGFloat = 980 + } + + fileprivate enum Motion { + static let contentSwitchDuration: TimeInterval = 0.16 + static let contentSwitchStartAlpha: CGFloat = 0.68 + static let searchFieldResizeDuration: TimeInterval = 0.14 + static let cardLiftDuration: TimeInterval = 0.16 + static let cardExpansionDuration: TimeInterval = 0.36 + static let actionRailDuration: TimeInterval = 0.14 + + static var cardExpansionTiming: CAMediaTimingFunction { + CAMediaTimingFunction(controlPoints: 0.25, 0.10, 0.25, 1.00) + } + } + + private enum CardSelectionInputSource { + case keyboard + case mouse + } + + private enum CategoryHoverTarget: Equatable { + case sortMode(ClipboardSortMode) + case collection(String) + case stack } private enum CardDensity: String { case regular case compact + case expanded - static func fitting(width: CGFloat) -> CardDensity { - width > 0 && width < Metrics.compactCardThreshold ? .compact : .regular + static func fitting( + width: CGFloat, + height: CGFloat, + compactModeEnabled: Bool, + panelLayout: ClipboardPanelLayout + ) -> CardDensity { + if panelLayout == .vertical { + return .compact + } + if compactModeEnabled || (width > 0 && width < Metrics.compactCardThreshold) || (height > 0 && height < 390) { + return .compact + } + if width >= Metrics.expandedCardWidthThreshold && height >= Metrics.expandedCardHeightThreshold { + return .expanded + } + return .regular } var layout: ClipboardItemCardLayout { switch self { case .regular: return .regular case .compact: return .compact + case .expanded: return .expanded } } @@ -167,6 +354,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { switch self { case .regular: return 16 case .compact: return 12 + case .expanded: return 18 } } @@ -174,27 +362,44 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { switch self { case .regular: return 10 case .compact: return 8 + case .expanded: return 12 } } + var cardTopInset: CGFloat { + cardStackInset + } + + var cardBottomInset: CGFloat { + cardStackInset + } + var railHeight: CGFloat { - layout.height + (cardStackInset * 2) + 2 + let itemHeight: CGFloat + switch self { + case .compact: + itemHeight = ClipboardCardPresentation.horizontalFocus.size(for: layout).height + case .regular: + itemHeight = ClipboardCardPresentation.horizontalFocus.size(for: layout).height + case .expanded: + itemHeight = ClipboardCardPresentation.expanded.size(for: layout).height + } + return itemHeight + cardTopInset + cardBottomInset + 2 } var emptyStateMinimumWidth: CGFloat { switch self { case .regular: return Metrics.emptyStateMinimumWidth case .compact: return 420 + case .expanded: return Metrics.emptyStateMinimumWidth } } } private enum Palette { - static let panelBorder = NSColor.separatorColor.withAlphaComponent(0.18).cgColor - static let panelSurface = NSColor.windowBackgroundColor.withAlphaComponent(0.56).cgColor - static let panelShadow = NSColor.black.withAlphaComponent(0.18).cgColor - static let panelStatusSurface = NSColor.clear.cgColor - static let statusDivider = NSColor.clear.cgColor + static let panelBorder = NSColor.white.withAlphaComponent(0.38).cgColor + static let panelSurface = NSColor.windowBackgroundColor.withAlphaComponent(0.16).cgColor + static let panelShadow = NSColor.black.withAlphaComponent(0.24).cgColor } private enum StatusTone: String { @@ -203,21 +408,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { case warning case error case neutral - - var color: NSColor { - switch self { - case .ready: - return NSColor.systemGreen.withAlphaComponent(0.85) - case .action: - return NSColor.controlAccentColor.withAlphaComponent(0.9) - case .warning: - return NSColor.systemOrange.withAlphaComponent(0.9) - case .error: - return NSColor.systemRed.withAlphaComponent(0.9) - case .neutral: - return NSColor.secondaryLabelColor.withAlphaComponent(0.65) - } - } } private let viewModel: ClipboardPanelViewModel @@ -225,36 +415,103 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let onSettings: () -> Void private let onPreview: () -> Void - private let searchField = NSSearchField() - private let collectionScrollView = HorizontalRailScrollView() + private let searchField = ClipboardPanelSearchField() + private let searchControlContainer = NSView() + private let searchIconButton = NSButton() + private weak var categoryMenuButton: NSButton? + private weak var clearHistoryButton: NSButton? + private weak var settingsButton: NSButton? + private weak var utilityToolbarGroup: NSView? + private let collectionScrollView = HorizontalRailScrollView(edgeFadeWidth: 44) + private let collectionDocumentView = CollectionRailDocumentView() private let collectionStack = NSStackView() private let addCollectionButton = NSButton() - private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen, symbolName: "square.stack.3d.up.fill") - private let itemsStack = NSStackView() + private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen, symbolName: "square.stack.3d.up.fill", iconOnly: true) + private let itemsStack = ClipboardItemsDocumentView() private let scrollView = HorizontalRailScrollView() - private let statusLabel = NSTextField(labelWithString: "") - private let statusResultCountLabel = NSTextField(labelWithString: "") - private let statusIndicator = NSView() + private let bottomSpacer = NSView() private var emptyStateText: (title: String, detail: String)? private var mainStack: NSStackView? private var bottomSafeInset = Metrics.minimumBottomInset + private var currentStatusText = "" + private var currentResultCountText = "" private var currentStatusTone: StatusTone = .ready + private var currentPanelLayout: ClipboardPanelLayout = .vertical private var cardDensity: CardDensity = .regular private var scrollViewHeightConstraint: NSLayoutConstraint? private var searchFieldWidthConstraint: NSLayoutConstraint? - private weak var shelfChromeStack: NSStackView? + private var collectionScrollWidthConstraint: NSLayoutConstraint? + private var collectionRailLeadingConstraint: NSLayoutConstraint? + 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? private var cardViews: [ClipboardItemCardView] = [] + private var cardSlots: [Int: ClipboardItemCardSlotView] = [:] + private var cardItemCount = 0 + private var configuredSortModes: [ClipboardSortMode] = [] private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:] private var customCollectionButtons: [String: CollectionChipView] = [:] private var collectionChipOrder: [CollectionChipView] = [] - private var lastScrollContentWidth: CGFloat = 0 - private var lastCollectionViewportWidth: CGFloat = 0 + private var configuredCollectionNames: [String] = [] + private var lastScrollViewportSize: NSSize = .zero + private var cardLayoutAnimationGeneration = 0 + private var resetCollectionScrollPositionOnNextLayout = false + private var searchFieldPresentationIsExpanded = false + private var searchFieldPresentationRequested = false + private var searchFieldCollapsedWhileIdle = false + private var selectionScrollSuppressionCount = 0 + private var hoveredCardIndex: Int? + private var hoverSelectionRequiresFreshMouseMovement = false + private var hoverSelectionKeyboardBarrierLocation: NSPoint? + private var hoveredCategoryTarget: CategoryHoverTarget? + private var categoryHoverOriginalSelection: ClipboardCategorySelectionSnapshot? + private var categoryHoverGeneration = 0 + private var cardSelectionInputSource: CardSelectionInputSource = .mouse + #if DEBUG + private var animatedCardLayoutChangeCount = 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 { + let itemID: UUID + let fallbackIndex: Int + } + + private struct CardRenderContext { + let items: [ClipboardItem] + let collectionNames: [String] + let layout: ClipboardItemCardLayout + let selectedCollectionName: String? + let selectedCollectionColor: NSColor? + let canShowInClipboard: Bool + let reloadGeneration: Int + } + + private let initialCardRenderBudget = 12 + private let cardRenderPrefetchBuffer = 3 + private let cardRenderRetentionBuffer = 8 + private var currentCardRenderContext: CardRenderContext? + #if DEBUG private var collectionNameProviderForTesting: (() -> String?)? + private var textClipProviderForTesting: (() -> 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( @@ -282,24 +539,29 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func configureView() { - material = .underWindowBackground + material = .hudWindow blendingMode = .behindWindow state = .active + isEmphasized = true wantsLayer = true layer?.cornerRadius = Metrics.panelCornerRadius layer?.masksToBounds = false layer?.backgroundColor = Palette.panelSurface - layer?.borderWidth = 0.6 + layer?.borderWidth = 0.8 layer?.borderColor = Palette.panelBorder layer?.shadowColor = Palette.panelShadow - layer?.shadowOpacity = 0.18 - layer?.shadowRadius = 20 - layer?.shadowOffset = NSSize(width: 0, height: 10) + layer?.shadowOpacity = 0.28 + layer?.shadowRadius = 34 + layer?.shadowOffset = NSSize(width: 0, height: 14) + configureSearchIconButton() searchField.setAccessibilityLabel("Search clipboard history") searchField.delegate = self searchField.target = self searchField.action = #selector(searchFieldChanged) + searchField.onFocusStateChanged = { [weak self] in + self?.updateSearchFieldPresentation() + } searchField.sendsSearchStringImmediately = true searchField.sendsWholeSearchString = false searchField.isBezeled = true @@ -310,142 +572,147 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { ] ) searchField.bezelStyle = .roundedBezel - searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6) + searchField.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.68) searchField.focusRingType = .none - searchField.toolTip = "Search clipboard history. Supports app:Safari, type:image, date:2026-06-30, after:2026-06-01, and pinned:on." + searchField.toolTip = "Search clipboard history." + searchField.setAccessibilityHelp("Type to search clipboard history. Use the side category icons to filter results.") + searchField.translatesAutoresizingMaskIntoConstraints = false searchField.setContentCompressionResistancePriority(.required, for: .horizontal) searchField.setContentHuggingPriority(.required, for: .horizontal) - searchFieldWidthConstraint = searchField.widthAnchor.constraint(equalToConstant: Metrics.compactSearchWidth) - searchFieldWidthConstraint?.isActive = true - searchField.heightAnchor.constraint(equalToConstant: 30).isActive = true + configureSearchControlContainer() updateSearchFieldPresentation() - collectionStack.orientation = .horizontal - collectionStack.alignment = .centerY + collectionStack.orientation = .vertical + collectionStack.alignment = .centerX collectionStack.distribution = .fill - collectionStack.spacing = 10 + collectionStack.spacing = Metrics.collectionSideRailGap collectionStack.translatesAutoresizingMaskIntoConstraints = true - collectionStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - collectionStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + collectionStack.setContentCompressionResistancePriority(.required, for: .vertical) + collectionStack.setContentHuggingPriority(.required, for: .vertical) collectionStack.setAccessibilityLabel("Clipboard collections") - collectionScrollView.documentView = collectionStack - collectionScrollView.hasHorizontalScroller = true + collectionDocumentView.translatesAutoresizingMaskIntoConstraints = true + collectionDocumentView.addSubview(collectionStack) + collectionScrollView.documentView = collectionDocumentView + collectionScrollView.hasHorizontalScroller = false collectionScrollView.hasVerticalScroller = false collectionScrollView.autohidesScrollers = true collectionScrollView.scrollerStyle = .overlay collectionScrollView.drawsBackground = false collectionScrollView.borderType = .noBorder collectionScrollView.setAccessibilityLabel("Clipboard collections") - collectionScrollView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - collectionScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal) - collectionScrollView.heightAnchor.constraint(equalToConstant: 30).isActive = true + collectionScrollView.setContentCompressionResistancePriority(.required, for: .horizontal) + collectionScrollView.setContentHuggingPriority(.required, for: .horizontal) + collectionScrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + collectionScrollView.setContentHuggingPriority(.defaultLow, for: .vertical) configureAddCollectionButton() configureCollectionButtons() - let settingsButton = iconButton("gearshape", toolTip: "Settings", action: #selector(openSettings)) - let closeButton = iconButton("xmark.circle", toolTip: "Close", action: #selector(closePanel)) + let clearHistoryButton = iconButton( + "trash", + toolTip: "Clear History", + accessibilityHelp: "Clear history.", + action: #selector(showToolbarMenu(_:)) + ) + self.clearHistoryButton = clearHistoryButton + + let settingsButton = iconButton( + "gearshape", + toolTip: "Settings", + accessibilityHelp: "Open ClipBored settings. Keyboard shortcut: Command-Comma.", + action: #selector(openSettings) + ) + self.settingsButton = settingsButton let actionStrip = row([ - settingsButton, - closeButton + clearHistoryButton, + settingsButton ]) - actionStrip.spacing = 8 + actionStrip.spacing = 6 actionStrip.setContentCompressionResistancePriority(.required, for: .horizontal) let actionGroup = groupedToolbar(actionStrip) + utilityToolbarGroup = actionGroup actionGroup.setContentHuggingPriority(.required, for: .horizontal) actionGroup.setContentCompressionResistancePriority(.required, for: .horizontal) - let shelfChrome = row([ - searchField, - collectionScrollView, - actionGroup + let shelfChrome = NSView() + shelfChrome.translatesAutoresizingMaskIntoConstraints = false + shelfChrome.setContentHuggingPriority(.defaultLow, for: .horizontal) + shelfChrome.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + shelfChrome.setContentHuggingPriority(.defaultHigh, for: .vertical) + shelfChrome.setContentCompressionResistancePriority(.required, for: .vertical) + searchControlContainer.translatesAutoresizingMaskIntoConstraints = false + actionGroup.translatesAutoresizingMaskIntoConstraints = false + shelfChrome.addSubview(searchControlContainer) + shelfChrome.addSubview(actionGroup) + shelfChromeHeightConstraint = shelfChrome.heightAnchor.constraint(equalToConstant: Metrics.collectionRailHeight) + searchControlCenterYConstraint = searchControlContainer.centerYAnchor.constraint(equalTo: shelfChrome.centerYAnchor) + utilityToolbarCenterYConstraint = actionGroup.centerYAnchor.constraint(equalTo: shelfChrome.centerYAnchor) + NSLayoutConstraint.activate([ + shelfChromeHeightConstraint!, + searchControlContainer.leadingAnchor.constraint(equalTo: shelfChrome.leadingAnchor), + searchControlCenterYConstraint!, + actionGroup.trailingAnchor.constraint(equalTo: shelfChrome.trailingAnchor), + utilityToolbarCenterYConstraint!, + searchControlContainer.trailingAnchor.constraint(lessThanOrEqualTo: actionGroup.leadingAnchor, constant: -12) ]) - shelfChrome.spacing = 10 - shelfChrome.distribution = .fill - shelfChrome.setHuggingPriority(.defaultHigh, for: .vertical) - shelfChromeStack = shelfChrome + shelfChromeView = shelfChrome - itemsStack.orientation = .horizontal - itemsStack.alignment = .top + itemsStack.orientation = .vertical + itemsStack.usesFlippedCoordinates = true + itemsStack.alignment = .centerX applyCardDensity() itemsStack.translatesAutoresizingMaskIntoConstraints = true scrollView.documentView = itemsStack - scrollView.hasHorizontalScroller = true - scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false scrollView.borderType = .noBorder scrollView.setContentHuggingPriority(.required, for: .vertical) scrollView.setContentCompressionResistancePriority(.required, for: .vertical) + scrollView.onVisibleBoundsChanged = { [weak self] in + self?.renderCardsNearVisibleViewport() + } scrollViewHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: cardDensity.railHeight) scrollViewHeightConstraint?.isActive = true - statusLabel.font = .systemFont(ofSize: NSFont.systemFontSize - 1) - statusLabel.textColor = .secondaryLabelColor - statusLabel.lineBreakMode = .byTruncatingTail - statusLabel.maximumNumberOfLines = 1 - statusLabel.setContentCompressionResistancePriority(.required, for: .vertical) - statusLabel.toolTip = statusLabel.stringValue - statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - statusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - - statusIndicator.wantsLayer = true - statusIndicator.layer?.cornerRadius = 4 - statusIndicator.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.85).cgColor - statusIndicator.translatesAutoresizingMaskIntoConstraints = false - statusIndicator.widthAnchor.constraint(equalToConstant: 8).isActive = true - statusIndicator.heightAnchor.constraint(equalToConstant: 8).isActive = true - statusIndicator.setAccessibilityElement(false) - - let statusRow = row([statusIndicator, statusLabel, statusResultCountLabel]) - statusRow.distribution = .fill - statusRow.alignment = .centerY - statusRow.spacing = 8 - statusRow.setContentCompressionResistancePriority(.required, for: .vertical) - - statusResultCountLabel.alignment = .right - statusResultCountLabel.textColor = .secondaryLabelColor - statusResultCountLabel.lineBreakMode = .byTruncatingTail - statusResultCountLabel.maximumNumberOfLines = 1 - statusResultCountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - statusResultCountLabel.setContentHuggingPriority(.required, for: .horizontal) - statusResultCountLabel.setContentCompressionResistancePriority(.required, for: .vertical) - statusResultCountLabel.setContentHuggingPriority(.defaultLow, for: .vertical) - statusResultCountLabel.font = .systemFont(ofSize: NSFont.systemFontSize - 1, weight: .medium) - - statusResultCountLabel.setAccessibilityLabel("Result count") - statusLabel.setAccessibilityLabel("Status") + bottomSpacer.translatesAutoresizingMaskIntoConstraints = false + bottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical) + bottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) let headerStack = NSStackView(views: [shelfChrome]) headerStack.orientation = .vertical headerStack.alignment = .leading + headerStack.distribution = .fill headerStack.spacing = 0 headerStack.setContentCompressionResistancePriority(.required, for: .vertical) self.headerStack = headerStack - let statusContainer = NSView() - statusContainer.wantsLayer = true - statusContainer.layer?.backgroundColor = Palette.panelStatusSurface - statusContainer.layer?.cornerRadius = 8 - statusContainer.layer?.borderWidth = 0 - statusContainer.layer?.borderColor = Palette.statusDivider - statusContainer.translatesAutoresizingMaskIntoConstraints = false - statusContainer.addSubview(statusRow) - NSLayoutConstraint.activate([ - statusRow.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 8), - statusRow.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -8), - statusRow.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4), - statusRow.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -4), - statusContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.panelStatusBarHeight) - ]) + let contentStack = NSView() + contentStack.translatesAutoresizingMaskIntoConstraints = false + contentStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + contentStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + contentStack.setContentHuggingPriority(.defaultLow, for: .vertical) + contentStack.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + contentStack.addSubview(scrollView) + contentStack.addSubview(collectionScrollView) + self.contentStack = contentStack - let mainStack = NSStackView(views: [headerStack, scrollView, statusContainer]) + scrollView.translatesAutoresizingMaskIntoConstraints = false + collectionScrollView.translatesAutoresizingMaskIntoConstraints = false + collectionScrollWidthConstraint = collectionScrollView.widthAnchor.constraint(equalToConstant: Metrics.collectionSideRailWidth) + collectionScrollWidthConstraint?.priority = .required + collectionRailLeadingConstraint = collectionScrollView.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor) + + let mainStack = NSStackView(views: [headerStack, contentStack, bottomSpacer]) mainStack.orientation = .vertical mainStack.alignment = .leading - mainStack.spacing = 10 + mainStack.distribution = .fill + mainStack.detachesHiddenViews = true + mainStack.spacing = 8 mainStack.edgeInsets = contentInsets() mainStack.translatesAutoresizingMaskIntoConstraints = false addSubview(mainStack) @@ -458,10 +725,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { mainStack.bottomAnchor.constraint(equalTo: bottomAnchor), headerStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), shelfChrome.widthAnchor.constraint(equalTo: headerStack.widthAnchor), - scrollView.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), - statusContainer.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)) + contentStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), + scrollView.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentStack.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: contentStack.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor), + collectionRailLeadingConstraint!, + collectionScrollWidthConstraint!, + collectionScrollView.topAnchor.constraint(equalTo: contentStack.topAnchor), + collectionScrollView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor) ]) + applyPanelLayout(reloadItems: false) updateCollectionButtons() updateResultCount() } @@ -470,9 +745,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.onVisibleItemsChanged = { [weak self] _ in self?.handleVisibleItemsChanged() } + viewModel.onSearchTextChanged = { [weak self] _ in + self?.syncSearchFieldFromViewModel() + } viewModel.onSelectedIndexChanged = { [weak self] _ in self?.updateSelection() } + viewModel.onSelectedItemsChanged = { [weak self] in + self?.updateSelection() + self?.updateResultCount() + } viewModel.onStatusMessageChanged = { [weak self] message in self?.updateStatus(message) } @@ -483,14 +765,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.handleCollectionsChanged() } viewModel.onStackChanged = { [weak self] in - self?.reloadItems() - self?.updateSelection() - self?.configureCollectionButtons() - self?.updateStatus(self?.viewModel.statusMessage ?? "") + self?.handleStackChanged() } viewModel.onCaptureStatusChanged = { [weak self] in self?.updateStatus(self?.viewModel.statusMessage ?? "") } + viewModel.onCompactModeChanged = { [weak self] in + guard let self else { return } + _ = self.updateCardDensityForCurrentWidth() + } + viewModel.onPanelLayoutChanged = { [weak self] in + self?.applyPanelLayout() + } } private func row(_ views: [NSView]) -> NSStackView { @@ -502,12 +788,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func groupedToolbar(_ content: NSView) -> NSView { - let container = NSVisualEffectView() - container.material = .sidebar - container.blendingMode = .withinWindow + let container = NSView() container.wantsLayer = true container.translatesAutoresizingMaskIntoConstraints = false - container.layer?.cornerRadius = 0 + container.layer?.cornerRadius = Metrics.actionButtonSize / 2 container.layer?.borderWidth = 0 container.layer?.borderColor = NSColor.clear.cgColor container.layer?.backgroundColor = NSColor.clear.cgColor @@ -523,21 +807,542 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return container } + private func configureSearchIconButton() { + let toolTip = "Search" + let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: toolTip) + image?.isTemplate = true + searchIconButton.image = image + searchIconButton.imagePosition = .imageOnly + searchIconButton.imageScaling = .scaleProportionallyDown + searchIconButton.bezelStyle = .smallSquare + searchIconButton.isBordered = false + searchIconButton.wantsLayer = true + searchIconButton.layer?.cornerRadius = Metrics.searchControlSize / 2 + searchIconButton.layer?.borderWidth = 0.5 + searchIconButton.layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor + searchIconButton.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.24).cgColor + searchIconButton.contentTintColor = .secondaryLabelColor + searchIconButton.toolTip = toolTip + searchIconButton.target = self + searchIconButton.action = #selector(focusSearchFromIcon) + searchIconButton.setAccessibilityLabel(toolTip) + searchIconButton.setAccessibilityHelp("Search clipboard history.") + searchIconButton.translatesAutoresizingMaskIntoConstraints = false + searchIconButton.widthAnchor.constraint(equalToConstant: Metrics.searchControlSize).isActive = true + searchIconButton.heightAnchor.constraint(equalToConstant: Metrics.searchControlSize).isActive = true + } + + private func configureSearchControlContainer() { + searchControlContainer.translatesAutoresizingMaskIntoConstraints = false + searchControlContainer.wantsLayer = true + searchControlContainer.layer?.masksToBounds = true + searchControlContainer.setContentHuggingPriority(.required, for: .horizontal) + searchControlContainer.setContentCompressionResistancePriority(.required, for: .horizontal) + searchFieldWidthConstraint = searchControlContainer.widthAnchor.constraint(equalToConstant: Metrics.searchControlSize) + searchFieldWidthConstraint?.isActive = true + searchControlContainer.heightAnchor.constraint(equalToConstant: Metrics.searchControlSize).isActive = true + + searchControlContainer.addSubview(searchField) + searchControlContainer.addSubview(searchIconButton) + NSLayoutConstraint.activate([ + searchField.leadingAnchor.constraint(equalTo: searchControlContainer.leadingAnchor), + searchField.trailingAnchor.constraint(equalTo: searchControlContainer.trailingAnchor), + searchField.topAnchor.constraint(equalTo: searchControlContainer.topAnchor), + searchField.bottomAnchor.constraint(equalTo: searchControlContainer.bottomAnchor), + searchIconButton.leadingAnchor.constraint(equalTo: searchControlContainer.leadingAnchor), + searchIconButton.topAnchor.constraint(equalTo: searchControlContainer.topAnchor), + searchIconButton.bottomAnchor.constraint(equalTo: searchControlContainer.bottomAnchor) + ]) + } + + #if DEBUG + private func searchFilterMenu(now: Date = Date()) -> NSMenu { + let menu = NSMenu(title: "Search Filters") + menu.autoenablesItems = false + + let typeMenu = NSMenu(title: "Type") + addSearchFilterItem("Text", token: "type:text", to: typeMenu) + addSearchFilterItem("Rich Text", token: "type:richtext", to: typeMenu) + addSearchFilterItem("Links", token: "type:link", to: typeMenu) + addSearchFilterItem("Images", token: "type:image", to: typeMenu) + addSearchFilterItem("Files", token: "type:file", to: typeMenu) + addSearchFilterItem("PDFs", token: "type:pdf", to: typeMenu) + addSearchFilterItem("Audio", token: "type:audio", to: typeMenu) + addSearchFilterItem("Videos", token: "type:video", to: typeMenu) + addSearchFilterItem("Colors", token: "type:color", to: typeMenu) + addSearchFilterItem("Code", token: "type:code", to: typeMenu) + addSubmenu(typeMenu, titled: "Type", to: menu) + + let dateMenu = NSMenu(title: "Date") + addSearchFilterItem("Today", token: "date:\(Self.searchFilterDateString(now))", to: dateMenu) + addSearchFilterItem("Last 7 Days", token: "after:\(Self.searchFilterDateString(Self.searchFilterDate(byAddingDays: -6, to: now)))", to: dateMenu) + addSearchFilterItem("Before Today", token: "before:\(Self.searchFilterDateString(now))", to: dateMenu) + addSubmenu(dateMenu, titled: "Date", to: menu) + + addSearchFilterItem("Pinned Items", token: "pinned:on", to: menu) + + if !viewModel.searchFilterSourceAppNames.isEmpty { + let sourceMenu = NSMenu(title: "Copied From") + for sourceApp in viewModel.searchFilterSourceAppNames { + addSearchFilterItem(sourceApp, token: "app:\(Self.quotedSearchFilterValue(sourceApp))", to: sourceMenu) + } + addSubmenu(sourceMenu, titled: "Copied From", to: menu) + } + + if !viewModel.searchFilterDeviceNames.isEmpty { + let deviceMenu = NSMenu(title: "Device") + for deviceName in viewModel.searchFilterDeviceNames { + addSearchFilterItem(deviceName, token: "device:\(Self.quotedSearchFilterValue(deviceName))", to: deviceMenu) + } + addSubmenu(deviceMenu, titled: "Device", to: menu) + } + + if !viewModel.collectionNames.isEmpty { + let pinboardMenu = NSMenu(title: "Pinboard") + for collectionName in viewModel.collectionNames { + addSearchFilterItem(collectionName, token: "pinboard:\(Self.quotedSearchFilterValue(collectionName))", to: pinboardMenu) + } + addSubmenu(pinboardMenu, titled: "Pinboard", to: menu) + } + + return menu + } + + private func addSubmenu(_ submenu: NSMenu, titled title: String, to menu: NSMenu) { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.submenu = submenu + item.isEnabled = true + menu.addItem(item) + } + + private func addSearchFilterItem(_ title: String, token: String, to menu: NSMenu) { + let item = NSMenuItem(title: title, action: #selector(applySearchFilterFromMenu(_:)), keyEquivalent: "") + item.target = self + item.isEnabled = true + item.representedObject = token + menu.addItem(item) + } + + private func applySearchFilterToken(_ token: String) { + let current = searchField.stringValue.clipboardTrimmed + let updatedQuery = Self.searchQuery(current, applyingFilterToken: token) + if searchField.stringValue != updatedQuery { + searchField.stringValue = updatedQuery + } + updateSearchText() + focusSearchField() + } + + private static func searchQuery(_ query: String, applyingFilterToken token: String) -> String { + guard !query.isEmpty else { return token } + let currentParts = searchFilterParts(from: query) + let tokenParts = searchFilterParts(from: token) + guard tokenParts.count == 1, let tokenPart = tokenParts.first else { return query } + + if currentParts.contains(where: { $0.canonical == tokenPart.canonical }) { + return query + } + + if tokenPart.isExact, + let keyValueCanonical = tokenPart.keyValueCanonical, + let fuzzyIndex = currentParts.firstIndex(where: { + !$0.isExact && $0.keyValueCanonical == keyValueCanonical + }) { + var rawParts = currentParts.map(\.raw) + rawParts[fuzzyIndex] = token + return rawParts.joined(separator: " ") + } + + if let replacementKeys = replacementKeyFamily(for: tokenPart.canonicalKey), + currentParts.contains(where: { part in + part.canonicalKey.map { replacementKeys.contains($0) } ?? false + }) { + var rawParts: [String] = [] + var insertedReplacement = false + for part in currentParts { + guard let canonicalKey = part.canonicalKey, + replacementKeys.contains(canonicalKey) else { + rawParts.append(part.raw) + continue + } + if !insertedReplacement { + rawParts.append(token) + insertedReplacement = true + } + } + if !insertedReplacement { + rawParts.append(token) + } + return rawParts.joined(separator: " ") + } + + return "\(query) \(token)" + } + + private static func replacementKeyFamily(for key: String?) -> Set? { + guard let key else { return nil } + let dateKeys: Set = ["after", "before", "date"] + if dateKeys.contains(key) { + return dateKeys + } + if key == "pinned" { + return ["pinned"] + } + if key == "type" { + return ["type"] + } + return nil + } + + private static func searchFilterParts(from query: String) -> [ClipboardSearchFilterPart] { + var parts: [ClipboardSearchFilterPart] = [] + var current = "" + var quotedBy: Character? + var isEscaping = false + + func flushCurrent() { + let part = current.clipboardTrimmed + if !part.isEmpty { + parts.append(searchFilterPart(from: part)) + } + current = "" + } + + for character in query { + if isEscaping { + current.append("\\") + current.append(character) + isEscaping = false + continue + } + + if character == "\\" && quotedBy != nil { + isEscaping = true + continue + } + + if character == "\"" || character == "'" { + if quotedBy == character { + current.append(character) + quotedBy = nil + continue + } + if quotedBy == nil { + quotedBy = character + current.append(character) + continue + } + } + + if character.isWhitespace && quotedBy == nil { + flushCurrent() + } else { + current.append(character) + } + } + + if isEscaping { + current.append("\\") + } + flushCurrent() + return parts + } + + private static func searchFilterPart(from rawPart: String) -> ClipboardSearchFilterPart { + guard let delimiter = rawPart.firstIndex(of: ":") else { + return plainSearchFilterPart(from: rawPart) + } + + let key = canonicalSearchFilterKey(String(rawPart[.. ClipboardSearchFilterPart { + let canonical = normalizedSearchFilterValue(rawPart) + return ClipboardSearchFilterPart( + raw: rawPart, + canonical: canonical, + canonicalKey: nil, + keyValueCanonical: nil, + isExact: false + ) + } + + private static func isSupportedSearchFilterPart( + canonicalKey key: String, + rawValue: String? = nil, + normalizedValue: String + ) -> Bool { + guard !normalizedValue.isEmpty else { return false } + switch key { + case "app", "device", "pinboard": + return true + case "type": + let rawValues = rawValue.map(searchFilterStructuredValues) ?? [normalizedValue] + let values = rawValues + .map { unquotedSearchFilterValue($0.clipboardTrimmed) ?? $0 } + .map(normalizedSearchFilterValue) + .filter { !$0.isEmpty } + return !values.isEmpty && values.allSatisfy(isSupportedSearchFilterType) + case "pinned": + return isSupportedSearchFilterBoolean(normalizedValue) + case "after", "before", "date": + return isSupportedSearchFilterDate(normalizedValue) + default: + return false + } + } + + private static func searchFilterStructuredValues(from value: String) -> [String] { + var values: [String] = [] + var current = "" + var quotedBy: Character? + var isEscaping = false + + func flushCurrent() { + values.append(current) + current = "" + } + + for character in value { + if isEscaping { + current.append("\\") + current.append(character) + isEscaping = false + continue + } + + if character == "\\" && quotedBy != nil { + isEscaping = true + continue + } + + if character == "\"" || character == "'" { + if quotedBy == character { + current.append(character) + quotedBy = nil + continue + } + if quotedBy == nil { + quotedBy = character + current.append(character) + continue + } + } + + if character == "," && quotedBy == nil { + flushCurrent() + } else { + current.append(character) + } + } + + if isEscaping { + current.append("\\") + } + flushCurrent() + return values + } + + private static func isSupportedSearchFilterType(_ value: String) -> Bool { + switch value { + case "text", "plain", + "richtext", "rich-text", "rtf", "html", + "note", "notes", "writing", + "code", "snippet", "snippets", "source", "programming", "script", "scripts", "json", "css", "sql", + "link", "links", "url", "urls", "web", + "image", "images", "photo", "photos", "picture", "pictures", + "file", "files", "finder", + "pdf", "pdfs", "document", "documents", + "audio", "sound", "music", + "video", "videos", "movie", "movies", "mp4", "quicktime", "mov", + "color", "colors", "swatch", "swatches", "hex", + "unknown", "item": + return true + default: + return false + } + } + + private static func isSupportedSearchFilterBoolean(_ value: String) -> Bool { + switch value { + case "1", "true", "yes", "y", "on", + "0", "false", "no", "n", "off": + return true + default: + return false + } + } + + private static func isSupportedSearchFilterDate(_ value: String) -> Bool { + let formatter = DateFormatter() + formatter.calendar = searchFilterCalendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = searchFilterCalendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + formatter.isLenient = false + guard let date = formatter.date(from: value) else { return false } + return formatter.string(from: date) == value + } + + private static func canonicalSearchFilterKey(_ key: String) -> String { + switch normalizedSearchFilterValue(key) { + case "source", "from": + return "app" + case "devices", "machine", "computer": + return "device" + case "collection", "folder", "list", "pinboards", "board", "boards": + return "pinboard" + case "kind": + return "type" + case "pin": + return "pinned" + case "on": + return "date" + case "since": + return "after" + case "until": + return "before" + default: + return normalizedSearchFilterValue(key) + } + } + + private static func unquotedSearchFilterValue(_ value: String) -> String? { + guard let first = value.first, + (first == "\"" || first == "'"), + value.last == first, + value.count >= 2 else { + return nil + } + let inner = String(value.dropFirst().dropLast()) + var unescaped = "" + var isEscaping = false + for character in inner { + if isEscaping { + unescaped.append(character) + isEscaping = false + } else if character == "\\" { + isEscaping = true + } else { + unescaped.append(character) + } + } + if isEscaping { + unescaped.append("\\") + } + return unescaped + } + + private static func normalizedSearchFilterValue(_ value: String) -> String { + value.clipboardTrimmed + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) + .lowercased() + } + + private static func quotedSearchFilterValue(_ value: String) -> String { + let sanitized = value.clipboardTrimmed + let escaped = sanitized + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + private static func searchFilterDateString(_ date: Date) -> String { + let formatter = DateFormatter() + let calendar = searchFilterCalendar + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func searchFilterDate(byAddingDays days: Int, to date: Date) -> Date { + searchFilterCalendar.date(byAdding: .day, value: days, to: date) ?? date + } + + private static var searchFilterCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = .autoupdatingCurrent + return calendar + } + #endif + + private func categoryMenu() -> NSMenu { + let menu = NSMenu(title: "Categories") + menu.autoenablesItems = false + return menu + } + + private func clearHistoryMenu() -> NSMenu { + let menu = NSMenu(title: "Clear History") + menu.autoenablesItems = false + for (title, seconds) in [("Past Hour", 3600.0), ("Past Day", 86_400.0), ("All Time", -1.0)] { + let item = NSMenuItem(title: title, action: #selector(performToolbarMenuItem(_:)), keyEquivalent: "") + item.target = self + item.representedObject = seconds + menu.addItem(item) + } + return menu + } + private func configureCollectionButtons() { collectionButtons.removeAll() customCollectionButtons.removeAll() collectionChipOrder.removeAll() + let visibleSortModes = visibleSortModesForCurrentCounts() + configuredSortModes = visibleSortModes + configuredCollectionNames = viewModel.collectionNames for view in collectionStack.arrangedSubviews { (view as? CollectionChipView)?.clearKeyboardFocus() collectionStack.removeArrangedSubview(view) view.removeFromSuperview() } - for mode in ClipboardSortMode.allCases { - let chip = CollectionChipView(title: collectionTitle(for: mode), color: collectionColor(for: mode), symbolName: collectionSymbol(for: mode)) + for mode in visibleSortModes { + let chip = CollectionChipView( + title: collectionTitle(for: mode), + color: collectionColor(for: mode), + symbolName: collectionSymbol(for: mode), + iconOnly: true + ) chip.toolTip = mode.title - chip.onPress = { [weak self] in - self?.viewModel.sortMode = mode + chip.onPress = { [weak self] extending in + guard let self else { return } + self.commitCategoryHoverPreview() + self.collapseSearchFieldIfIdle() + self.viewModel.selectSortMode(mode, extending: extending) + self.viewModel.commitCategorySelection() + } + chip.onHover = { [weak self] hovered in + self?.handleCategoryHover(.sortMode(mode), hovered: hovered) } configureCollectionKeyboardNavigation(for: chip) collectionButtons[mode] = chip @@ -545,11 +1350,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionStack.addArrangedSubview(chip) } - for collectionName in viewModel.collectionNames { - let chip = CollectionChipView(title: collectionName, color: collectionColor(forCollectionNamed: collectionName)) + for collectionName in configuredCollectionNames { + let chip = CollectionChipView(title: collectionName, color: collectionColor(forCollectionNamed: collectionName), iconOnly: true) chip.toolTip = collectionName - chip.onPress = { [weak self] in - self?.viewModel.selectCollection(named: collectionName) + chip.onPress = { [weak self] extending in + guard let self else { return } + self.commitCategoryHoverPreview() + self.collapseSearchFieldIfIdle() + self.viewModel.selectCollection(named: collectionName, extending: extending) + } + chip.onHover = { [weak self] hovered in + self?.handleCategoryHover(.collection(collectionName), hovered: hovered) } chip.onDropItem = { [weak self] itemID in self?.viewModel.assignItem(withID: itemID, to: collectionName) @@ -557,6 +1368,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onEdit = { [weak self] in self?.editCollection(named: collectionName) } + chip.onExport = { [weak self] in + self?.exportCollection(named: collectionName) + } chip.onDelete = { [weak self] in self?.deleteCollection(named: collectionName) } @@ -568,13 +1382,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { configureStackChip() collectionStack.addArrangedSubview(addCollectionButton) updateAddCollectionButtonState() - sizeCollectionDocument() + resetCollectionScrollPositionOnNextLayout = true + sizeCollectionDocument(consumingScrollReset: false) } private func configureStackChip() { stackChip.toolTip = "Queued clips" - stackChip.onPress = { [weak self] in - self?.viewModel.selectStack() + stackChip.onPress = { [weak self] _ in + guard let self else { return } + self.commitCategoryHoverPreview() + self.collapseSearchFieldIfIdle() + self.viewModel.selectStack() + } + stackChip.onHover = { [weak self] hovered in + self?.handleCategoryHover(.stack, hovered: hovered) } stackChip.onAddVisibleToStack = { [weak self] in self?.viewModel.addVisibleItemsToStack() @@ -594,13 +1415,68 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.onClearStack = { [weak self] in self?.viewModel.clearStack() } + stackChip.onToggleStackCapture = { [weak self] in + self?.viewModel.toggleStackCaptureMode() + } configureCollectionKeyboardNavigation(for: stackChip) - if viewModel.stackCount > 0 { + stackChip.setStackCaptureActive(viewModel.isStackCaptureEnabled) + stackChip.toolTip = viewModel.isStackCaptureEnabled ? "Stack capture is on" : "Queued clips" + if shouldShowStackChip { collectionChipOrder.append(stackChip) collectionStack.addArrangedSubview(stackChip) } } + private func handleCategoryHover(_ target: CategoryHoverTarget, hovered: Bool) { + if hovered { + previewCategory(target) + } else { + scheduleCategoryHoverRestoreIfNeeded(exiting: target) + } + } + + private func previewCategory(_ target: CategoryHoverTarget) { + if categoryHoverOriginalSelection == nil { + categoryHoverOriginalSelection = viewModel.categorySelectionSnapshot() + } + hoveredCategoryTarget = target + categoryHoverGeneration += 1 + collapseSearchFieldIfIdle() + switch target { + case .sortMode(let mode): + viewModel.previewSortMode(mode) + case .collection(let name): + viewModel.previewCollection(named: name) + case .stack: + viewModel.previewStack() + } + } + + private func scheduleCategoryHoverRestoreIfNeeded(exiting target: CategoryHoverTarget) { + guard hoveredCategoryTarget == target else { return } + hoveredCategoryTarget = nil + categoryHoverGeneration += 1 + let generation = categoryHoverGeneration + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard self.categoryHoverGeneration == generation else { return } + guard self.hoveredCategoryTarget == nil else { return } + self.restoreCategoryHoverPreviewIfNeeded() + } + } + + private func commitCategoryHoverPreview() { + categoryHoverGeneration += 1 + hoveredCategoryTarget = nil + categoryHoverOriginalSelection = nil + } + + private func restoreCategoryHoverPreviewIfNeeded() { + guard let snapshot = categoryHoverOriginalSelection else { return } + categoryHoverOriginalSelection = nil + viewModel.restoreCategorySelection(snapshot) + } + private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) { chip.onStartSearch = { [weak self] text in self?.startSearchFromShelf(text) @@ -608,6 +1484,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.onMoveFocus = { [weak self, weak chip] delta in self?.moveCollectionFocus(from: chip, delta: delta) } + chip.onMoveCardSelection = { [weak self] delta in + self?.moveSelectionFromFocusedCard(delta) + } chip.onSelectFirst = { [weak self] in self?.selectCollectionChip(at: 0) } @@ -617,6 +1496,37 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } } + private func visibleSortModesForCurrentCounts() -> [ClipboardSortMode] { + ClipboardSortMode.allCases.filter { viewModel.shouldShowCategory(for: $0) } + } + + private func primarySortModesForCurrentCounts() -> [ClipboardSortMode] { + let visibleSortModes = visibleSortModesForCurrentCounts() + var primaryModes: [ClipboardSortMode] = [] + + for mode in [ClipboardSortMode.mostRecent, .mostUsed, .text, .links, .images] { + if visibleSortModes.contains(mode) { + primaryModes.append(mode) + } + } + + for mode in visibleSortModes where viewModel.isSortModeCategorySelected(mode) { + if !primaryModes.contains(mode) { + primaryModes.append(mode) + } + } + + return primaryModes + } + + private func overflowSortModesForCurrentCounts() -> [ClipboardSortMode] { + visibleSortModesForCurrentCounts().filter { !isPrimarySortMode($0) } + } + + private func isPrimarySortMode(_ mode: ClipboardSortMode) -> Bool { + primarySortModesForCurrentCounts().contains(mode) + } + private func configureAddCollectionButton() { let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection") image?.isTemplate = true @@ -625,18 +1535,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addCollectionButton.imageScaling = .scaleProportionallyDown addCollectionButton.isBordered = false addCollectionButton.wantsLayer = true - addCollectionButton.layer?.cornerRadius = 13 - addCollectionButton.layer?.borderWidth = 0.6 - addCollectionButton.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor - addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor - addCollectionButton.contentTintColor = .secondaryLabelColor + addCollectionButton.layer?.cornerRadius = Metrics.collectionSideRailItemSize / 2 + addCollectionButton.layer?.borderWidth = 0.5 + addCollectionButton.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.10).cgColor + addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.18).cgColor + addCollectionButton.contentTintColor = .tertiaryLabelColor addCollectionButton.toolTip = "New collection" addCollectionButton.setAccessibilityLabel("New collection") + addCollectionButton.setAccessibilityHelp("Create a new Pinboard collection. Keyboard shortcut: Shift-Command-N.") addCollectionButton.target = self addCollectionButton.action = #selector(createCollectionFromToolbar) addCollectionButton.translatesAutoresizingMaskIntoConstraints = false - addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true - addCollectionButton.heightAnchor.constraint(equalToConstant: 26).isActive = true + addCollectionButton.widthAnchor.constraint(equalToConstant: Metrics.collectionSideRailItemSize).isActive = true + addCollectionButton.heightAnchor.constraint(equalToConstant: Metrics.collectionSideRailItemSize).isActive = true } private func collectionTitle(for mode: ClipboardSortMode) -> String { @@ -681,19 +1592,85 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func applyCardDensity() { itemsStack.spacing = cardDensity.cardSpacing - let inset = cardDensity.cardStackInset itemsStack.edgeInsets = NSEdgeInsets( - top: inset, - left: inset, - bottom: inset, - right: inset + top: cardDensity.cardTopInset, + left: cardDensity.cardStackInset, + bottom: cardDensity.cardBottomInset, + right: cardDensity.cardStackInset ) scrollViewHeightConstraint?.constant = cardDensity.railHeight } + private func currentCardLayout(viewportWidth: CGFloat? = nil) -> ClipboardItemCardLayout { + let layout = cardDensity.layout + guard currentPanelLayout == .vertical else { return layout } + + let visibleWidth = viewportWidth ?? scrollView.contentView.bounds.width + guard visibleWidth > 1 else { return layout } + + let availableCardWidth = floor(visibleWidth - (cardDensity.cardStackInset * 2)) + guard availableCardWidth > 0, availableCardWidth < layout.width else { return layout } + + return layout.withWidth(max(Metrics.minimumVerticalCardWidth, availableCardWidth)) + } + + private var verticalCardLeadingReserve: CGFloat { + currentPanelLayout == .vertical ? Metrics.collectionSideRailWidth : 0 + } + + private func verticalCardMinXInContent(width cardWidth: CGFloat, viewportWidth: CGFloat) -> CGFloat { + let reserve = verticalCardLeadingReserve + let availableWidth = max(1, viewportWidth - reserve) + return reserve + max(cardDensity.cardStackInset, floor((availableWidth - cardWidth) / 2)) + } + + private func applyPanelLayout(reloadItems shouldReload: Bool = true) { + currentPanelLayout = viewModel.panelLayout + let isVertical = currentPanelLayout == .vertical + + updateShelfChromeLayout(isVertical: isVertical) + itemsStack.orientation = isVertical ? .vertical : .horizontal + itemsStack.usesFlippedCoordinates = isVertical + itemsStack.alignment = isVertical ? .centerX : .bottom + scrollView.hasHorizontalScroller = !isVertical + scrollView.hasVerticalScroller = isVertical + scrollViewHeightConstraint?.isActive = !isVertical + bottomSpacer.isHidden = isVertical + scrollView.setContentHuggingPriority(isVertical ? .defaultLow : .required, for: .vertical) + scrollView.setContentCompressionResistancePriority(isVertical ? .defaultLow : .required, for: .vertical) + + lastScrollViewportSize = .zero + updateSearchFieldPresentation() + sizeCollectionDocument(consumingScrollReset: false) + applyCardDensity() + + if shouldReload { + let densityChanged = updateCardDensityForCurrentWidth() + if !densityChanged { + reloadItems() + } + updateSelection() + } + needsLayout = true + } + + private func updateShelfChromeLayout(isVertical: Bool) { + shelfChromeHeightConstraint?.constant = Metrics.collectionRailHeight + searchControlCenterYConstraint?.constant = 0 + utilityToolbarCenterYConstraint?.constant = 0 + shelfChromeView?.needsLayout = true + headerStack?.needsLayout = true + contentStack?.needsLayout = true + } + @discardableResult private func updateCardDensityForCurrentWidth() -> Bool { - let targetDensity = CardDensity.fitting(width: bounds.width) + let targetDensity = CardDensity.fitting( + width: bounds.width, + height: bounds.height, + compactModeEnabled: viewModel.isCompactModeEnabled, + panelLayout: currentPanelLayout + ) guard targetDensity != cardDensity else { return false } cardDensity = targetDensity @@ -711,7 +1688,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { ) } - private func iconButton(_ systemName: String, toolTip: String, action: Selector) -> NSButton { + private func iconButton( + _ systemName: String, + toolTip: String, + accessibilityHelp: String, + action: Selector + ) -> NSButton { let button = NSButton(title: "", target: self, action: action) let image = NSImage(systemSymbolName: systemName, accessibilityDescription: toolTip) image?.isTemplate = true @@ -721,27 +1703,84 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { button.bezelStyle = .smallSquare button.isBordered = false button.wantsLayer = true - button.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.14).cgColor - button.layer?.cornerRadius = 7 + button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor + button.layer?.borderWidth = 0.5 + button.layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor + button.layer?.cornerRadius = Metrics.actionButtonSize / 2 button.toolTip = toolTip button.contentTintColor = .secondaryLabelColor button.setAccessibilityLabel(toolTip) + button.setAccessibilityHelp(accessibilityHelp) button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: Metrics.actionButtonSize).isActive = true button.heightAnchor.constraint(equalToConstant: Metrics.actionButtonSize).isActive = true return button } + private func cardReuseFingerprint( + for item: ClipboardItem, + index: Int, + layout: ClipboardItemCardLayout, + collectionNames: [String], + selectedCollectionName: String?, + selectedCollectionColor: NSColor?, + canShowInClipboard: Bool + ) -> ClipboardItemCardReuseFingerprint { + let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName) + let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName) + let activeCollectionName = normalizedSelectedCollection == normalizedItemCollection ? normalizedSelectedCollection : nil + let activeCollectionColorHex = activeCollectionName.map { name in + ClipboardCollectionVisuals.hexString( + for: selectedCollectionColor ?? collectionColor(forCollectionNamed: name) + ) + } + return ClipboardItemCardReuseFingerprint( + id: item.id, + kind: item.kind, + displayText: item.displayText, + payload: item.payload, + payloadHash: item.payloadHash, + createdAt: item.createdAt, + lastUsedAt: item.lastUsedAt, + useCount: item.useCount, + sourceApp: item.sourceApp, + imagePath: item.imagePath, + thumbnailPath: item.thumbnailPath, + isPinned: item.isPinned, + sourceAppBundleID: item.sourceAppBundleId, + ocrText: item.ocrText, + collectionName: normalizedItemCollection, + customTitle: item.customTitle, + sourceDeviceName: item.sourceDeviceName, + layout: layout, + collectionNames: collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }, + canShowInClipboard: canShowInClipboard, + activeCollectionName: activeCollectionName, + activeCollectionColorHex: activeCollectionColorHex, + quickPasteNumber: index < 9 ? index + 1 : nil + ) + } + private func reloadItems() { + cardReloadGeneration += 1 + let reloadGeneration = cardReloadGeneration + let focusedCardReloadTarget = focusedCardReloadTarget() + currentCardRenderContext = nil cardViews.removeAll() - lastScrollContentWidth = 0 + cardSlots.removeAll() + cardItemCount = 0 + lastScrollViewportSize = .zero for view in itemsStack.arrangedSubviews { itemsStack.removeArrangedSubview(view) + } + for view in itemsStack.subviews { view.removeFromSuperview() } let items = viewModel.visibleItems + let shouldAnimateReload = window != nil && !defersVisualReloads if items.isEmpty { + lastSynchronousCardRenderCount = 0 emptyStateText = emptyStateCopy() scrollView.documentView = emptyStateView() } else { @@ -750,142 +1789,518 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollView.documentView = itemsStack } let collectionNames = viewModel.collectionNames - let layout = cardDensity.layout - for (index, item) in items.enumerated() { - let card = ClipboardItemCardView( - item: item, - thumbnail: viewModel.thumbnail(for: item), - index: index, - layout: layout, - collectionNames: collectionNames, - isStacked: viewModel.isItemStacked(at: index), - stackCount: viewModel.stackCount, - canShowInClipboard: viewModel.canShowVisibleItemsInClipboard, - selectedCollectionName: viewModel.selectedCollectionName, - selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) } - ) - card.onSelect = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - } - card.onStartSearch = { [weak self] text in - self?.startSearchFromShelf(text) - } - card.onMoveSelection = { [weak self] delta in - self?.moveSelectionFromFocusedCard(delta) - } - card.onPageSelection = { [weak self] direction in - guard let self else { return } - self.moveSelectionFromFocusedCard(direction * self.visibleCardPageStep) - } - card.onSelectFirst = { [weak self] in - self?.selectFirstCardFromFocusedCard() - } - card.onSelectLast = { [weak self] in - self?.selectLastCardFromFocusedCard() - } - card.onPaste = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.pasteSelected() - } - card.onCopy = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.copySelected() - } - card.onPastePlainText = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.pasteSelectedPlainText() - } - card.onCopyPlainText = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.copySelectedPlainText() - } - card.onToggleStack = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.toggleSelectedStackMembership() - } - card.onAddVisibleToStack = { [weak self] in - self?.viewModel.addVisibleItemsToStack() - } - card.onPasteStackNext = { [weak self] in - self?.viewModel.pasteNextStackItem() - } - card.onCopyStackNext = { [weak self] in - self?.viewModel.copyNextStackItem() - } - card.onPasteStackText = { [weak self] in - self?.viewModel.pasteStackAsText() - } - card.onCopyStackText = { [weak self] in - self?.viewModel.copyStackAsText() - } - card.onClearStack = { [weak self] in - self?.viewModel.clearStack() - } - card.onShowInClipboard = { [weak self] selected in - self?.showSelectedInClipboard(at: selected) - } - card.onRename = { [weak self] selected in - self?.renameClip(at: selected) - } - card.onEditText = { [weak self] selected in - self?.editText(at: selected) - } - card.onPreview = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.onPreview() - } - card.onPasteboardWriters = { [weak self] selected in - self?.viewModel.pasteboardWriters(forItemAt: selected) ?? [] - } - card.onOpen = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.openSelected() - } - card.onReveal = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.revealSelected() - } - card.onTogglePin = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.togglePinSelected() - } - card.onAssignCollection = { [weak self] selected, collectionName in - self?.viewModel.selectItem(at: selected) - self?.viewModel.assignSelected(to: collectionName) - } - card.onIgnoreSourceApp = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.ignoreSelectedSourceApp() - } - card.onIgnoreKind = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.ignoreSelectedKind() - } - card.onDelete = { [weak self] selected in - self?.viewModel.selectItem(at: selected) - self?.viewModel.deleteSelected() - } - cardViews.append(card) - itemsStack.addArrangedSubview(card) - } + let layout = currentCardLayout() + let selectedCollectionName = viewModel.selectedCollectionName + let selectedCollectionColor = selectedCollectionName.map { collectionColor(forCollectionNamed: $0) } + let canShowInClipboard = viewModel.canShowVisibleItemsInClipboard + + currentCardRenderContext = CardRenderContext( + items: items, + collectionNames: collectionNames, + layout: layout, + selectedCollectionName: selectedCollectionName, + selectedCollectionColor: selectedCollectionColor, + canShowInClipboard: canShowInClipboard, + reloadGeneration: reloadGeneration + ) + cardItemCount = items.count sizeItemsDocument(itemCount: items.count) + + let synchronousRange = initialCardRenderRange(itemCount: items.count) + lastSynchronousCardRenderCount = synchronousRange.count + renderCards(in: synchronousRange, reloadGeneration: reloadGeneration) } updateSelection() + restoreFocusedCardIfNeeded(focusedCardReloadTarget) updateStatus(viewModel.statusMessage) updateResultCount() + animateContentReloadIfNeeded(shouldAnimateReload) + } + + private func initialCardRenderRange(itemCount: Int) -> Range { + guard itemCount > 0 else { return 0..<0 } + return 0.. 0 else { + return + } + let visibleRange = visibleCardIndexRange() + renderCards(in: visibleRange, reloadGeneration: cardReloadGeneration) + pruneRenderedCards(outside: retainedCardIndexRange(around: visibleRange)) + } + + private func visibleCardIndexRange() -> Range { + let itemCount = cardItemCount + guard itemCount > 0 else { return 0..<0 } + + let visibleBounds = scrollView.contentView.bounds + let layout = currentCardLayout() + let spacing = cardDensity.cardSpacing + let edgeInsets = itemsStack.edgeInsets + let startPosition: CGFloat + let endPosition: CGFloat + let leadingInset: CGFloat + + if currentPanelLayout == .vertical { + startPosition = visibleBounds.minY + endPosition = visibleBounds.maxY + leadingInset = edgeInsets.top + } else { + startPosition = visibleBounds.minX + endPosition = visibleBounds.maxX + leadingInset = edgeInsets.left + } + + var firstVisible = itemCount + var lastVisible = 0 + var cursor = leadingInset + for index in 0..= startPosition && firstVisible == itemCount { + firstVisible = index + } + if itemStart <= endPosition { + lastVisible = index + } + cursor = itemEnd + spacing + if itemStart > endPosition { + break + } + } + + guard firstVisible < itemCount else { return 0.., reloadGeneration: Int) { + guard let context = currentCardRenderContext, + context.reloadGeneration == reloadGeneration, + !range.isEmpty else { + return + } + + let clampedRange = max(0, range.lowerBound)..= 0, index < cardItemCount else { return } + renderCards(in: index..<(index + 1), reloadGeneration: cardReloadGeneration) + } + + private func cardView(at index: Int) -> ClipboardItemCardView? { + cardSlots[index]?.card + } + + private func cardSlot( + at index: Int, + itemID: UUID, + layout: ClipboardItemCardLayout + ) -> ClipboardItemCardSlotView { + if let slot = cardSlots[index] { + slot.frame = cardSlotFrame(at: index, layout: layout) + return slot + } + + let slot = ClipboardItemCardSlotView(itemID: itemID, layout: layout) + slot.frame = cardSlotFrame(at: index, layout: layout) + cardSlots[index] = slot + itemsStack.addSubview(slot) + return slot + } + + private func renderedCardsInItemOrder() -> [ClipboardItemCardView] { + cardSlots.keys.sorted().compactMap { cardSlots[$0]?.card } + } + + private func retainedCardIndexRange(around renderedRange: Range) -> Range { + let itemCount = cardItemCount + guard itemCount > 0 else { return 0..<0 } + let lowerBound = max(0, renderedRange.lowerBound - cardRenderRetentionBuffer) + let upperBound = min(itemCount, renderedRange.upperBound + cardRenderRetentionBuffer) + return lowerBound..) { + guard !cardSlots.isEmpty else { return } + let activeIndex = viewModel.selectedIndex + let focusedIndex = focusedRenderedCardIndex() + let removableIndexes = cardSlots.keys.filter { index in + !retainedRange.contains(index) && index != activeIndex && index != focusedIndex + } + guard !removableIndexes.isEmpty else { return } + + for index in removableIndexes { + cardSlots.removeValue(forKey: index)?.removeFromSuperview() + } + cardViews = renderedCardsInItemOrder() + } + + private func focusedRenderedCardIndex() -> Int? { + guard let responder = window?.firstResponder as? NSView else { return nil } + for (index, slot) in cardSlots { + guard let card = slot.card else { continue } + if responder === card || responder.isDescendant(of: card) { + return index + } + } + return nil + } + + private func positionRenderedCardSlots(animated: Bool = false) { + let layout = currentCardLayout() + guard animated, window != nil else { + for (index, slot) in cardSlots { + slot.frame = cardSlotFrame(at: index, layout: layout) + } + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = Motion.cardExpansionDuration + context.timingFunction = Motion.cardExpansionTiming + for (index, slot) in cardSlots { + slot.animator().frame = cardSlotFrame(at: index, layout: layout) + } + } + } + + private func setRenderedCardSlotsToTargetFrames() { + let layout = currentCardLayout() + for (index, slot) in cardSlots { + slot.frame = cardSlotFrame(at: index, layout: layout) + } + } + + private func cardSlotFrame(at index: Int, layout: ClipboardItemCardLayout) -> NSRect { + let edgeInsets = itemsStack.edgeInsets + let size = cardPresentation(at: index).size(for: layout) + if currentPanelLayout == .vertical { + let x = verticalCardMinXInContent(width: size.width, viewportWidth: itemsStack.bounds.width) + let y = cardSlotOffset(at: index, layout: layout) + edgeInsets.top + return NSRect(x: x, y: y, width: size.width, height: size.height) + } + + let x = edgeInsets.left + cardSlotOffset(at: index, layout: layout) + let y = edgeInsets.bottom + return NSRect(x: x, y: y, width: size.width, height: size.height) + } + + private func cardPresentation(at index: Int) -> ClipboardCardPresentation { + if currentPanelLayout == .vertical { + return index == visuallyExpandedCardIndex ? .verticalFocus : .verticalRow + } + + guard index == viewModel.selectedIndex else { + return .horizontalIcon + } + if cardDensity == .expanded { + return .expanded + } + return .horizontalFocus + } + + private var visuallyExpandedCardIndex: Int? { + if currentPanelLayout == .vertical { + if let hoveredCardIndex { + return hoveredCardIndex + } + return cardSelectionInputSource == .keyboard ? viewModel.selectedIndex : nil + } + return viewModel.selectedIndex + } + + private func cardSlotPrimaryLength(at index: Int, layout: ClipboardItemCardLayout) -> CGFloat { + let size = cardPresentation(at: index).size(for: layout) + return currentPanelLayout == .vertical ? size.height : size.width + } + + private func cardSlotOffset(at targetIndex: Int, layout: ClipboardItemCardLayout) -> CGFloat { + guard targetIndex > 0 else { return 0 } + var offset: CGFloat = 0 + for index in 0.. 0, + self?.viewModel.selectedIndex == selected { + self?.selectionScrollSuppressionCount = 0 + return + } + if mode == .activate { + self?.claimKeyboardCardSelection() + } else { + self?.cardSelectionInputSource = .mouse + } + if mode != .activate { + self?.selectionScrollSuppressionCount = 0 + } + self?.collapseSearchFieldIfIdle() + self?.viewModel.selectItem(at: selected, mode: mode) + } + card.onHover = { [weak self, weak card] selected, mouseLocation in + guard let self else { return false } + guard self.claimMouseHoverSelection(at: selected, mouseLocation: mouseLocation) else { + return false + } + self.viewModel.selectItem(at: selected, mode: .hover) + if !self.isSearchFieldEditing, let card { + self.window?.makeFirstResponder(card) + } + return true + } + card.onHoverExit = { [weak self] index in + self?.handleCardHoverExit(at: index) + } + card.onStartSearch = { [weak self] text in + self?.startSearchFromShelf(text) + } + card.onMoveSelection = { [weak self] delta in + self?.moveSelectionFromFocusedCard(delta) + } + card.onExtendSelection = { [weak self] delta in + self?.extendSelectionFromFocusedCard(delta) + } + card.onPageSelection = { [weak self] direction in + guard let self else { return } + self.moveSelectionFromFocusedCard(direction * self.visibleCardPageStep) + } + card.onPageExtendSelection = { [weak self] direction in + guard let self else { return } + self.extendSelectionFromFocusedCard(direction * self.visibleCardPageStep) + } + card.onSelectFirst = { [weak self] in + self?.selectFirstCardFromFocusedCard() + } + card.onSelectLast = { [weak self] in + self?.selectLastCardFromFocusedCard() + } + card.onExtendSelectionToFirst = { [weak self] in + self?.extendSelectionToFirstCardFromFocusedCard() + } + card.onExtendSelectionToLast = { [weak self] in + self?.extendSelectionToLastCardFromFocusedCard() + } + card.onSelectAll = { [weak self] in + self?.viewModel.selectAllVisibleItems() + self?.focusSelectedCard() + } + card.onPaste = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.pasteSelected() + } + card.onCopy = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.copySelected() + } + card.onPastePlainText = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.pasteSelectedPlainText() + } + card.onCopyPlainText = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.copySelectedPlainText() + } + card.onPasteSelectionText = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.pasteSelectedItemsAsText() + } + card.onCopySelectionText = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.copySelectedItemsAsText() + } + card.onAddSelectionToStack = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.addSelectedItemsToStack() + } + card.onToggleStack = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.toggleSelectedStackMembership() + } + card.onAddVisibleToStack = { [weak self] in + self?.viewModel.addVisibleItemsToStack() + } + card.onPasteStackNext = { [weak self] in + self?.viewModel.pasteNextStackItem() + } + card.onCopyStackNext = { [weak self] in + self?.viewModel.copyNextStackItem() + } + card.onPasteStackText = { [weak self] in + self?.viewModel.pasteStackAsText() + } + card.onCopyStackText = { [weak self] in + self?.viewModel.copyStackAsText() + } + card.onClearStack = { [weak self] in + self?.viewModel.clearStack() + } + card.onShowInClipboard = { [weak self] selected in + self?.showSelectedInClipboard(at: selected) + } + card.onRename = { [weak self] selected in + self?.renameClip(at: selected) + } + card.onEditText = { [weak self] selected in + self?.editText(at: selected) + } + card.onRotateImage = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.rotateSelectedImageClockwise() + } + card.onExtractImageText = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.extractTextFromSelectedImage() + } + card.onPreview = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.onPreview() + } + card.onPasteboardWriters = { [weak self] selected in + 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() + } + card.onReveal = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.revealSelected() + } + card.onTogglePin = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.togglePinSelected() + } + card.onAssignCollection = { [weak self] selected, collectionName in + self?.viewModel.selectItem(at: selected) + self?.viewModel.assignSelected(to: collectionName) + } + card.onIgnoreSourceApp = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.ignoreSelectedSourceApp() + } + card.onIgnoreKind = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.viewModel.ignoreSelectedKind() + } + card.onDelete = { [weak self] selected in + self?.viewModel.selectItem(at: selected, mode: .activate) + self?.viewModel.deleteSelected() + } + card.onUndoDelete = { [weak self] in + self?.viewModel.undoLastDelete() + } + } + + private func focusedCardReloadTarget() -> FocusedCardReloadTarget? { + guard let responder = window?.firstResponder as? NSView else { return nil } + for card in cardViews where responder === card || responder.isDescendant(of: card) { + return FocusedCardReloadTarget(itemID: card.representedItemID, fallbackIndex: card.representedIndex) + } + return nil + } + + private func restoreFocusedCardIfNeeded(_ target: FocusedCardReloadTarget?) { + guard let target else { return } + + if cardView(at: target.fallbackIndex) == nil { + renderCardIfNeeded(at: target.fallbackIndex) + } + let fallbackCard = cardView(at: target.fallbackIndex) + let focusedCard = cardViews.first { $0.representedItemID == target.itemID } ?? fallbackCard + guard let focusedCard else { return } + window?.makeFirstResponder(focusedCard) + } + + private func animateContentReloadIfNeeded(_ shouldAnimate: Bool) { + guard shouldAnimate else { return } + scrollView.alphaValue = Motion.contentSwitchStartAlpha + NSAnimationContext.runAnimationGroup { context in + context.duration = Motion.contentSwitchDuration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + scrollView.animator().alphaValue = 1.0 + } } private func handleVisibleItemsChanged() { if defersVisualReloads { pendingItemReload = true + if collectionChipsNeedRebuild { + pendingCollectionReload = true + } updateStatus(viewModel.statusMessage) updateResultCount() return } + let shouldRestoreCollectionFocus = collectionChipsNeedRebuild && isCollectionChipFocused + rebuildCollectionChipsIfNeeded() reloadItems() updateCollectionButtons() + if shouldRestoreCollectionFocus { + focusSelectedCollectionChip() + } } private func handleCollectionsChanged() { @@ -894,13 +2309,41 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return } - configureCollectionButtons() + let shouldRestoreCollectionFocus = collectionChipsNeedRebuild && isCollectionChipFocused + rebuildCollectionChipsIfNeeded() updateCollectionButtons() + if shouldRestoreCollectionFocus { + focusSelectedCollectionChip() + } + } + + private func handleStackChanged() { + if defersVisualReloads { + pendingCollectionReload = true + updateStatus(viewModel.statusMessage) + updateResultCount() + return + } + + let shouldReloadCollections = collectionChipsNeedRebuild || stackChipVisibilityNeedsRebuild + let shouldRestoreCollectionFocus = shouldReloadCollections && isCollectionChipFocused + if shouldReloadCollections { + configureCollectionButtons() + } + updateVisibleCardStackState() + updateSelection() + updateCollectionButtons() + updateStatus(viewModel.statusMessage) + updateResultCount() + if shouldRestoreCollectionFocus { + focusSelectedCollectionChip() + } } private func flushDeferredVisualReloads() { let shouldReloadItems = pendingItemReload - let shouldReloadCollections = pendingCollectionReload + let shouldReloadCollections = pendingCollectionReload || collectionChipsNeedRebuild + let shouldRestoreCollectionFocus = shouldReloadCollections && isCollectionChipFocused pendingItemReload = false pendingCollectionReload = false @@ -913,20 +2356,76 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if shouldReloadItems || shouldReloadCollections { updateCollectionButtons() } + if shouldRestoreCollectionFocus { + focusSelectedCollectionChip() + } + } + + private var collectionChipsNeedRebuild: Bool { + configuredCollectionNames != viewModel.collectionNames + || configuredSortModes != visibleSortModesForCurrentCounts() + } + + private var stackChipVisibilityNeedsRebuild: Bool { + collectionStack.arrangedSubviews.contains(stackChip) != shouldShowStackChip + } + + private var shouldShowStackChip: Bool { + viewModel.stackCount > 0 || viewModel.isStackCaptureEnabled + } + + private func rebuildCollectionChipsIfNeeded() { + guard collectionChipsNeedRebuild else { return } + configureCollectionButtons() + } + + private func updateVisibleCardStackState() { + for card in cardViews { + let index = card.representedIndex + card.setStackState(isStacked: viewModel.isItemStacked(at: index), stackCount: viewModel.stackCount) + } + } + + private func applyCurrentCardState(_ card: ClipboardItemCardView, index: Int, animated: Bool = false) { + let stableHeaderRollout = animated && currentPanelLayout == .vertical + let isVisuallyExpanded = index == visuallyExpandedCardIndex + card.setPresentation( + cardPresentation(at: index), + animated: animated, + stableHeaderRollout: stableHeaderRollout + ) + card.setStackState(isStacked: viewModel.isItemStacked(at: index), stackCount: viewModel.stackCount) + card.setSelectionState( + active: isVisuallyExpanded || (currentPanelLayout != .vertical && index == viewModel.selectedIndex), + selected: viewModel.isItemSelected(at: index), + selectionCount: viewModel.selectedItemCount + ) } private func updateSelection() { - var selectedCard: ClipboardItemCardView? - for (index, card) in cardViews.enumerated() { - let selected = index == viewModel.selectedIndex - card.setSelected(selected) - if selected { - selectedCard = card + renderCardIfNeeded(at: viewModel.selectedIndex) + let shouldAnimateLayout = window != nil && cardItemCount > 0 + + var activeCard: ClipboardItemCardView? + for card in cardViews { + let index = card.representedIndex + applyCurrentCardState(card, index: index, animated: shouldAnimateLayout) + if index == viewModel.selectedIndex { + activeCard = card } } - if let selectedCard { - scrollCardIntoView(selectedCard) + if cardItemCount > 0 { + sizeItemsDocument(itemCount: cardItemCount, animated: shouldAnimateLayout) + } + + let shouldSuppressSelectionScroll = selectionScrollSuppressionCount > 0 + if shouldSuppressSelectionScroll { + selectionScrollSuppressionCount -= 1 + } + + if let activeCard, !shouldSuppressSelectionScroll { + scrollCardIntoView(activeCard) } updateAddCollectionButtonState() } @@ -936,14 +2435,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addCollectionButton.alphaValue = 1.0 } - private func scrollCardIntoView(_ card: NSView) { + private func scrollCardIntoView(_ card: ClipboardItemCardView) { guard scrollView.documentView === itemsStack else { return } guard card.window != nil else { return } scrollView.layoutSubtreeIfNeeded() itemsStack.layoutSubtreeIfNeeded() - let frame = card.convert(card.bounds, to: itemsStack) - let paddedFrame = frame.insetBy(dx: -cardDensity.cardSpacing, dy: 0) + let frame = cardSlotFrame(at: card.representedIndex, layout: currentCardLayout()) + let paddedFrame = currentPanelLayout == .vertical + ? frame.insetBy(dx: 0, dy: -cardDensity.cardSpacing) + : frame.insetBy(dx: -cardDensity.cardSpacing, dy: 0) itemsStack.scrollToVisible(paddedFrame) scrollView.reflectScrolledClipView(scrollView.contentView) } @@ -960,7 +2461,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { guard !collectionChipOrder.isEmpty else { return } let targetIndex = max(0, min(collectionChipOrder.count - 1, index)) let title = collectionChipOrder[targetIndex].titleText - collectionChipOrder[targetIndex].onPress() + collectionChipOrder[targetIndex].onPress(false) let focusedChip: CollectionChipView? if let rebuiltChip = collectionChipOrder.first(where: { $0.titleText == title }) { @@ -976,15 +2477,42 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollCollectionChipIntoView(focusedChip) } + private func focusSelectedCollectionChip() { + let selectedChip: CollectionChipView? + if viewModel.isStackFilterSelected { + selectedChip = stackChip + } else if let collectionName = viewModel.selectedCollectionName { + selectedChip = customCollectionButtons[collectionName] + } else { + selectedChip = configuredSortModes.compactMap { mode in + viewModel.isSortModeCategorySelected(mode) ? collectionButtons[mode] : nil + }.first + ?? collectionButtons[viewModel.sortMode] + } + guard let selectedChip = selectedChip ?? collectionChipOrder.first else { return } + collectionChipOrder.forEach { $0.clearKeyboardFocus() } + window?.makeFirstResponder(selectedChip) + selectedChip.setKeyboardFocusForPresentation(true) + scrollCollectionChipIntoView(selectedChip) + } + + private var isCollectionChipFocused: Bool { + guard let responder = window?.firstResponder as? NSView else { return false } + return collectionChipOrder.contains { chip in + responder === chip || responder.isDescendant(of: chip) + } + } + private func scrollCollectionChipIntoView(_ chip: NSView) { - guard collectionScrollView.documentView === collectionStack else { return } + guard collectionScrollView.documentView === collectionDocumentView else { return } guard chip.window != nil else { return } collectionScrollView.layoutSubtreeIfNeeded() + collectionDocumentView.layoutSubtreeIfNeeded() collectionStack.layoutSubtreeIfNeeded() - let frame = chip.convert(chip.bounds, to: collectionStack) - let paddedFrame = frame.insetBy(dx: -10, dy: 0) - collectionStack.scrollToVisible(paddedFrame) + let frame = chip.convert(chip.bounds, to: collectionDocumentView) + let paddedFrame = frame.insetBy(dx: -2, dy: -Metrics.collectionSideRailGap) + collectionDocumentView.scrollToVisible(paddedFrame) collectionScrollView.reflectScrolledClipView(collectionScrollView.contentView) } @@ -995,19 +2523,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } else if !viewModel.captureStatusMessage.isEmpty { text = viewModel.captureStatusMessage } else if viewModel.visibleItems.isEmpty { - text = "Capture is running. Accessibility permission is only needed for automatic paste." + text = viewModel.searchText.clipboardTrimmed.isEmpty + ? "Capture is running. Accessibility permission is only needed for automatic paste." + : "No clips match this search" } else { text = "Capture running" } - statusLabel.stringValue = text - statusLabel.toolTip = statusLabel.stringValue - updateStatusIndicator(for: text) - } - - private func updateStatusIndicator(for text: String) { + currentStatusText = text currentStatusTone = statusTone(for: text) - statusIndicator.layer?.backgroundColor = currentStatusTone.color.cgColor - statusIndicator.toolTip = text } private func statusTone(for text: String) -> StatusTone { @@ -1015,17 +2538,30 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") { return .ready } - if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("renamed") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") { - return .action - } - if lower.hasPrefix("error") || lower.contains("failed") { + if lower.hasPrefix("error") || lower.contains("failed") || lower.contains("could not") { return .error } - if lower.hasPrefix("skipped") || lower.contains("ignored") || lower.contains("paused") { + if lower.hasPrefix("skipped") + || lower.contains("ignored") + || lower.contains("paused") + || lower.contains("grant accessibility") + || lower.contains("not granted") + || lower.contains("at least one") + || lower.contains("already") + || lower.contains("cannot be empty") + || lower.contains("has no text") + || lower.contains("is empty") + || lower.contains("no changes") + || lower.contains("no collections") + || lower.contains("no selected") + || lower.contains("no text") + || lower.contains("no visible") + || lower.contains("nothing to") + || lower.contains("unavailable") { return .warning } - if lower.contains("could not") || lower.contains("not granted") { - return .error + if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("renamed") || lower.hasPrefix("rotated") || lower.hasPrefix("extracted") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("deleted") || lower.hasPrefix("restored") || lower.hasPrefix("selected") || lower.hasPrefix("exported") || lower.hasPrefix("cleared") || lower.hasPrefix("showing") || lower.hasPrefix("compact mode ") || lower.hasPrefix("stack capture is ") { + return .action } return .neutral } @@ -1034,48 +2570,59 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { let count = viewModel.visibleItems.count let noun = count == 1 ? "clip" : "clips" let text: String - if !viewModel.searchText.clipboardTrimmed.isEmpty { + if viewModel.selectedItemCount > 1 { + text = "\(viewModel.selectedItemCount) selected of \(count) \(noun)" + } else if !viewModel.searchText.clipboardTrimmed.isEmpty { text = "\(count) \(noun) matching" } else { text = "\(count) \(noun)" } - statusResultCountLabel.stringValue = text - statusResultCountLabel.toolTip = text + currentResultCountText = text } 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 = NSTextView(frame: NSRect(x: 0, y: 0, width: 460, height: 180)) - textView.string = currentText - textView.font = .systemFont(ofSize: 13) - textView.isRichText = false - textView.allowsUndo = true - textView.textContainerInset = NSSize(width: 10, height: 10) - textView.usesAdaptiveColorMappingForDarkAppearance = true - - let scrollView = NSScrollView(frame: textView.frame) - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .bezelBorder - scrollView.documentView = textView + let textView = Self.makePlainTextEditor(initialText: currentText) + let accessoryView = textEditorAccessoryView(for: textView) let alert = NSAlert() alert.messageText = "Edit Text" - alert.accessoryView = scrollView + alert.accessoryView = accessoryView alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") alert.window.initialFirstResponder = textView + activeWritingToolsTextView = textView + defer { + if activeWritingToolsTextView === textView { + activeWritingToolsTextView = nil + } + } guard alert.runModal() == .alertFirstButtonReturn else { return } viewModel.updateSelectedText(to: textView.string) } + func editSelectedClip() { + editText(at: viewModel.selectedIndex) + } + 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" @@ -1093,9 +2640,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.updateSelectedTitle(to: input.stringValue) } + func renameSelectedClip() { + renameClip(at: viewModel.selectedIndex) + } + private func emptyStateView() -> NSView { - let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width) - let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight)) + let width = currentPanelLayout == .vertical + ? max(1, scrollView.contentView.bounds.width) + : max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width) + let height = max(cardDensity.railHeight, scrollView.contentView.bounds.height) + let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) let copy = emptyStateCopy() let title = NSTextField(labelWithString: copy.title) title.font = .systemFont(ofSize: 14, weight: .medium) @@ -1123,20 +2677,86 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return container } - private func sizeItemsDocument(itemCount: Int) { - let count = CGFloat(itemCount) - let contentWidth = (count * cardDensity.layout.width) - + max(0, count - 1) * cardDensity.cardSpacing - + (cardDensity.cardStackInset * 2) - let width = max(scrollView.contentView.bounds.width, contentWidth) - lastScrollContentWidth = width - itemsStack.frame = NSRect(x: 0, y: 0, width: width, height: currentListHeight()) - itemsStack.needsLayout = true - itemsStack.layoutSubtreeIfNeeded() + private func sizeItemsDocument(itemCount: Int, animated: Bool = false) { + cardLayoutAnimationGeneration += 1 + let animationGeneration = cardLayoutAnimationGeneration + let viewportSize = scrollView.contentView.bounds.size + let layout = currentCardLayout(viewportWidth: viewportSize.width) + let width: CGFloat + let height: CGFloat + if currentPanelLayout == .vertical { + let contentHeight = verticalCardRailContentLength(itemCount: itemCount, layout: layout) + + (cardDensity.cardStackInset * 2) + width = max(viewportSize.width, layout.width + (cardDensity.cardStackInset * 2)) + height = max(viewportSize.height, contentHeight) + } else { + let contentWidth = horizontalCardRailContentLength(itemCount: itemCount, layout: layout) + + (cardDensity.cardStackInset * 2) + width = max(viewportSize.width, contentWidth) + height = currentListHeight(itemCount: itemCount, layout: layout) + } + lastScrollViewportSize = viewportSize + let targetFrame = NSRect(x: 0, y: 0, width: width, height: height) + guard animated, window != nil else { + itemsStack.frame = targetFrame + positionRenderedCardSlots() + itemsStack.needsLayout = true + itemsStack.layoutSubtreeIfNeeded() + return + } + + #if DEBUG + animatedCardLayoutChangeCount += 1 + #endif + + NSAnimationContext.runAnimationGroup { context in + context.duration = Motion.cardExpansionDuration + context.timingFunction = Motion.cardExpansionTiming + itemsStack.animator().frame = targetFrame + positionRenderedCardSlots(animated: true) + } completionHandler: { [weak self] in + guard let self else { return } + guard self.cardLayoutAnimationGeneration == animationGeneration else { return } + self.itemsStack.frame = targetFrame + self.setRenderedCardSlotsToTargetFrames() + self.itemsStack.needsLayout = true + self.itemsStack.layoutSubtreeIfNeeded() + } } - private func currentListHeight() -> CGFloat { - cardDensity.layout.height + (cardDensity.cardStackInset * 2) + private func currentListHeight(itemCount: Int, layout: ClipboardItemCardLayout) -> CGFloat { + guard itemCount > 0 else { + return cardDensity.railHeight + } + var contentHeight: CGFloat = 0 + for index in 0.. CGFloat { + guard itemCount > 0 else { return 0 } + var length: CGFloat = 0 + for index in 0.. 0 { + length += cardDensity.cardSpacing + } + length += cardPresentation(at: index).size(for: layout).width + } + return length + } + + private func verticalCardRailContentLength(itemCount: Int, layout: ClipboardItemCardLayout) -> CGFloat { + guard itemCount > 0 else { return 0 } + var length: CGFloat = 0 + for index in 0.. 0 { + length += cardDensity.cardSpacing + } + length += cardPresentation(at: index).size(for: layout).height + } + return length } private func emptyStateCopy() -> (title: String, detail: String) { @@ -1147,6 +2767,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { ) } + if viewModel.isStackFilterSelected { + return viewModel.isStackCaptureEnabled + ? ("Stack capture is on", "Copied items will appear here in order.") + : ("Stack is empty", "Add clips manually or press Shift-Command-C to capture copies into Stack.") + } + if let collectionName = viewModel.selectedCollectionName { return ( "No clips in \(collectionName)", @@ -1188,16 +2814,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func updateCollectionButtons() { + let counts = viewModel.collectionCountSummary() for (mode, chip) in collectionButtons { - chip.setSelected(viewModel.selectedCollectionName == nil && mode == viewModel.sortMode) - chip.setCount(viewModel.collectionCount(for: mode)) + chip.setSelected(viewModel.isSortModeCategorySelected(mode)) + chip.setCount(counts.count(for: mode)) } for (name, chip) in customCollectionButtons { - chip.setSelected(viewModel.selectedCollectionName == name) - chip.setCount(viewModel.collectionCount(named: name)) + chip.setSelected(viewModel.isCollectionCategorySelected(named: name)) + chip.setCount(counts.count(named: name)) } stackChip.setSelected(viewModel.isStackFilterSelected) + stackChip.setStackCaptureActive(viewModel.isStackCaptureEnabled) stackChip.setCount(viewModel.stackCount) + categoryMenuButton?.isEnabled = !overflowSortModesForCurrentCounts().isEmpty sizeCollectionDocument() } @@ -1226,66 +2855,212 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return true } + override func mouseDown(with event: NSEvent) { + collapseSearchFieldIfIdle() + window?.makeFirstResponder(self) + super.mouseDown(with: event) + } + override func layout() { super.layout() _ = updateCardDensityForCurrentWidth() - let collectionViewportWidth = collectionScrollView.contentView.bounds.width - if collectionViewportWidth != lastCollectionViewportWidth { - lastCollectionViewportWidth = collectionViewportWidth - sizeCollectionDocument() - } + updateCollectionRailHorizontalPosition() + sizeCollectionDocument() guard !scrollView.frame.equalTo(.zero) else { return } - let contentWidth = scrollView.contentView.bounds.width - if contentWidth == lastScrollContentWidth { + let viewportSize = scrollView.contentView.bounds.size + if viewportSize == lastScrollViewportSize { return } - lastScrollContentWidth = contentWidth + lastScrollViewportSize = viewportSize - if cardViews.isEmpty { + if cardItemCount == 0 { guard let documentView = scrollView.documentView else { return } + let emptyStateWidth = currentPanelLayout == .vertical + ? max(1, scrollView.contentView.bounds.width) + : max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width) documentView.frame.size = NSSize( - width: max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width), - height: currentListHeight() + width: emptyStateWidth, + height: max(cardDensity.railHeight, scrollView.contentView.bounds.height) ) return } - sizeItemsDocument(itemCount: cardViews.count) + if let currentCardRenderContext, currentCardRenderContext.layout != currentCardLayout(viewportWidth: viewportSize.width) { + reloadItems() + return + } + + sizeItemsDocument(itemCount: cardItemCount) + renderCardsNearVisibleViewport() } - private func sizeCollectionDocument() { + private func sizeCollectionDocument(consumingScrollReset: Bool = true) { collectionStack.layoutSubtreeIfNeeded() - let contentWidth = ceil(collectionStack.fittingSize.width) - let viewportWidth = collectionScrollView.contentView.bounds.width - let width = max(contentWidth, viewportWidth) - collectionStack.frame = NSRect(x: 0, y: 0, width: width, height: 30) + let visibleSubviews = collectionStack.arrangedSubviews.filter { !$0.isHidden } + let rawContentHeight = ceil( + visibleSubviews.reduce(CGFloat(0)) { partialHeight, view in + partialHeight + view.fittingSize.height + } + + CGFloat(max(0, visibleSubviews.count - 1)) * collectionStack.spacing + ) + let targetViewportWidth = Metrics.collectionSideRailWidth + if abs((collectionScrollWidthConstraint?.constant ?? 0) - targetViewportWidth) > 0.5 { + collectionScrollWidthConstraint?.constant = targetViewportWidth + shelfChromeView?.layoutSubtreeIfNeeded() + collectionScrollView.layoutSubtreeIfNeeded() + } + let viewportHeight = max(1, collectionScrollView.contentView.bounds.height) + let contentWidth = targetViewportWidth + let currentInsets = collectionStack.edgeInsets + if abs(currentInsets.top) > 0.5 + || abs(currentInsets.left) > 0.5 + || abs(currentInsets.bottom) > 0.5 + || abs(currentInsets.right) > 0.5 { + collectionStack.edgeInsets = NSEdgeInsetsZero + } + let contentHeight = rawContentHeight + let documentHeight = max(viewportHeight, contentHeight) + let stackY = max(0, floor((documentHeight - contentHeight) / 2)) + collectionDocumentView.frame = NSRect(x: 0, y: 0, width: contentWidth, height: documentHeight) + collectionStack.frame = NSRect(x: 0, y: stackY, width: contentWidth, height: contentHeight) + let maxOffset = max(0, documentHeight - viewportHeight) + let currentOrigin = collectionScrollView.contentView.bounds.origin + let targetY: CGFloat + if resetCollectionScrollPositionOnNextLayout { + targetY = 0 + if consumingScrollReset { + resetCollectionScrollPositionOnNextLayout = false + } + } else { + targetY = min(max(currentOrigin.y, 0), maxOffset) + } + if abs(currentOrigin.y - targetY) > 0.5 || abs(currentOrigin.x) > 0.5 { + collectionScrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) + collectionScrollView.reflectScrolledClipView(collectionScrollView.contentView) + } } - private func updateSearchFieldPresentation() { - let hasSearchText = !searchField.stringValue.clipboardTrimmed.isEmpty - searchFieldWidthConstraint?.constant = hasSearchText - ? Metrics.expandedSearchWidth - : Metrics.compactSearchWidth + private func updateCollectionRailHorizontalPosition() { + guard currentPanelLayout == .vertical else { + collectionRailLeadingConstraint?.constant = 0 + return + } + guard let contentStack else { return } + + contentStack.layoutSubtreeIfNeeded() + scrollView.layoutSubtreeIfNeeded() + + let railWidth = Metrics.collectionSideRailWidth + let viewportWidth = max(1, scrollView.contentView.bounds.width) + let layout = currentCardLayout(viewportWidth: viewportWidth) + let cardMinXInContent = verticalCardMinXInContent(width: layout.width, viewportWidth: viewportWidth) + let contentMinXInPanel = contentStack.convert(contentStack.bounds, to: self).minX + let cardMinXInPanel = contentMinXInPanel + cardMinXInContent + let desiredMidXInPanel = cardMinXInPanel / 2 + let centeredLeading = desiredMidXInPanel - contentMinXInPanel - (railWidth / 2) + let maximumLeadingBeforeCard = cardMinXInContent - railWidth - Metrics.collectionRailCardClearance + let targetLeading = max(0, min(centeredLeading, maximumLeadingBeforeCard)) + + if abs((collectionRailLeadingConstraint?.constant ?? 0) - targetLeading) > 0.5 { + collectionRailLeadingConstraint?.constant = targetLeading + contentStack.layoutSubtreeIfNeeded() + collectionScrollView.layoutSubtreeIfNeeded() + } + } + + private func updateSearchFieldPresentation(animated: Bool = true) { + let hasSearchText = !searchField.stringValue.isEmpty + let isActive = hasSearchText + || searchFieldPresentationRequested + || searchField.hasKeyboardFocusForPresentation + || (isSearchFieldEditing && !searchFieldCollapsedWhileIdle) + let expandedWidth: CGFloat = currentPanelLayout == .vertical + ? Metrics.verticalExpandedSearchWidth + : Metrics.expandedSearchWidth + let targetWidth: CGFloat = isActive ? expandedWidth : Metrics.searchControlSize + let wasActive = searchFieldPresentationIsExpanded + let widthChanged = searchFieldWidthConstraint.map { abs($0.constant - targetWidth) > 0.5 } ?? false + let visibilityChanged = wasActive != isActive + || searchField.isHidden == isActive + || searchIconButton.isHidden == isActive + searchFieldPresentationIsExpanded = isActive searchField.placeholderAttributedString = NSAttributedString( - string: hasSearchText ? "Search clips" : "", + string: isActive ? "Search clips" : "", attributes: [ .foregroundColor: NSColor.tertiaryLabelColor ] ) - needsLayout = true - shelfChromeStack?.needsLayout = true - headerStack?.needsLayout = true + + guard animated, window != nil, widthChanged || visibilityChanged else { + searchFieldWidthConstraint?.constant = targetWidth + searchField.alphaValue = isActive ? 1 : 0 + searchIconButton.alphaValue = isActive ? 0 : 1 + searchField.isHidden = !isActive + searchIconButton.isHidden = isActive + needsLayout = true + shelfChromeView?.needsLayout = true + headerStack?.needsLayout = true + sizeCollectionDocument(consumingScrollReset: false) + return + } + + searchField.isHidden = false + searchIconButton.isHidden = false + searchField.alphaValue = wasActive ? 1 : 0 + searchIconButton.alphaValue = wasActive ? 0 : 1 + shelfChromeView?.layoutSubtreeIfNeeded() + headerStack?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + + NSAnimationContext.runAnimationGroup { context in + context.duration = Motion.searchFieldResizeDuration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + searchFieldWidthConstraint?.animator().constant = targetWidth + searchField.animator().alphaValue = isActive ? 1 : 0 + searchIconButton.animator().alphaValue = isActive ? 0 : 1 + shelfChromeView?.layoutSubtreeIfNeeded() + headerStack?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + } completionHandler: { [weak self] in + guard let self, self.searchFieldPresentationIsExpanded == isActive else { return } + self.searchFieldWidthConstraint?.constant = targetWidth + self.searchField.alphaValue = isActive ? 1 : 0 + self.searchIconButton.alphaValue = isActive ? 0 : 1 + self.searchField.isHidden = !isActive + self.searchIconButton.isHidden = isActive + self.sizeCollectionDocument(consumingScrollReset: false) + } + } + + private func collapseSearchFieldIfIdle() { + guard searchField.stringValue.isEmpty else { + updateSearchFieldPresentation() + return + } + searchFieldPresentationRequested = false + searchFieldCollapsedWhileIdle = true + searchField.setKeyboardFocusForPresentation(false) + updateSearchFieldPresentation() } func focusSearchField() { - window?.makeFirstResponder(searchField) + searchFieldPresentationRequested = true + searchFieldCollapsedWhileIdle = false + searchField.setKeyboardFocusForPresentation(true) updateSearchFieldPresentation() + _ = window?.makeFirstResponder(searchField) + } + + @discardableResult + func focusSearchOrShowFilters() -> Bool { + focusSearchField() + return false } @discardableResult func clearSearchForKeyboardCancel() -> Bool { - guard !searchField.stringValue.clipboardTrimmed.isEmpty else { return false } + guard !searchField.stringValue.isEmpty else { return false } searchField.stringValue = "" updateSearchText() focusSearchField() @@ -1295,7 +3070,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func startSearchFromShelf(_ text: String) { guard !text.isEmpty else { return } focusSearchField() - searchField.stringValue += text + let current = searchField.stringValue.clipboardTrimmed + searchField.stringValue = current.isEmpty ? text : "\(current) \(text)" updateSearchText() } @@ -1306,21 +3082,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var visibleCardPageStep: Int { - let span = cardDensity.layout.width + cardDensity.cardSpacing + let layout = currentCardLayout() + let span = currentPanelLayout == .vertical + ? layout.height + layout.activeLift + cardDensity.cardSpacing + : layout.width + cardDensity.cardSpacing + let visibleExtent = currentPanelLayout == .vertical + ? scrollView.contentView.bounds.height + : scrollView.contentView.bounds.width guard span > 0 else { return 1 } - return max(1, Int(floor(scrollView.contentView.bounds.width / span))) + return max(1, Int(floor(visibleExtent / span))) } func focusSelectedCardForKeyboardNavigation() { + claimKeyboardCardSelection() focusSelectedCard() } + override var acceptsFirstResponder: Bool { + true + } + + override func keyDown(with event: NSEvent) { + if let text = shelfSearchText(from: event) { + startSearchFromShelf(text) + } else { + super.keyDown(with: event) + } + } + func prepareForShow() { if !searchField.stringValue.isEmpty { searchField.stringValue = "" updateSearchText() } - focusSearchField() + collapseSearchFieldIfIdle() } func beginOpeningTransition() { @@ -1340,6 +3135,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.count } + var debugCardSlotCount: Int { + cardSlots.count + } + + var debugCardItemCount: Int { + cardItemCount + } + + var debugLastSynchronousCardRenderCount: Int { + lastSynchronousCardRenderCount + } + var debugIsDeferringVisualReloads: Bool { defersVisualReloads } @@ -1360,6 +3167,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { layer?.cornerRadius ?? 0 } + var debugPanelMaterial: NSVisualEffectView.Material { + material + } + + var debugPanelSurfaceAlpha: CGFloat { + Self.debugAlpha(layer?.backgroundColor) + } + + var debugPanelBorderAlpha: CGFloat { + Self.debugAlpha(layer?.borderColor) + } + var debugCardAccessibilityLabels: [String] { cardViews.compactMap { $0.accessibilityLabel() } } @@ -1382,6 +3201,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } } + 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) } @@ -1398,6 +3229,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) } @@ -1406,10 +3249,116 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardDensity.rawValue } + var debugPanelLayout: String { + currentPanelLayout.title + } + + var debugResizeHandleIsHidden: Bool { + true + } + + var debugItemsStackOrientation: NSUserInterfaceLayoutOrientation { + itemsStack.orientation + } + + private var debugToolbarButtons: [NSButton] { + [categoryMenuButton, 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 debugSearchFilterButtonAccessibilityHelp: String { + "" + } + + var debugAddCollectionButtonAccessibilityHelp: String { + addCollectionButton.accessibilityHelp() ?? "" + } + + var debugCompactModeButtonAccessibilityValue: String { + "" + } + + func debugToggleCompactMode() { + toggleCompactMode() + } + 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) } @@ -1418,6 +3367,24 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 ?? "" } @@ -1442,26 +3409,145 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 < cardViews.count else { + guard viewModel.selectedIndex >= 0, viewModel.selectedIndex < cardItemCount else { return .zero } - let card = cardViews[viewModel.selectedIndex] - return card.convert(card.bounds, to: itemsStack) + 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 { + let frame = debugSelectedCardFrameInPanel + return currentPanelLayout == .vertical ? frame.minY : bounds.maxY - frame.maxY } var debugCardRailVisibleRect: NSRect { scrollView.contentView.bounds } + 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 } @@ -1470,8 +3556,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { scrollView.overflowFadeVisibility } + var debugCardRailOverflowFadeWidth: CGFloat { + scrollView.overflowFadeWidth + } + func debugScrollCardRailVertically(deltaY: CGFloat) { - scrollView.scrollHorizontallyByVerticalDelta(deltaY) + if currentPanelLayout == .vertical { + scrollView.scrollVerticallyByVerticalDelta(deltaY) + } else { + scrollView.scrollHorizontallyByVerticalDelta(deltaY) + } } var debugFirstCardMenuTitles: [String] { @@ -1494,10 +3588,46 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 } @@ -1510,10 +3640,55 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 debugStackCornerLabels: [String] { cardViews.map(\.debugStackCornerLabel) } + 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 + } + var debugStackCornerHiddenStates: [Bool] { cardViews.map(\.debugStackCornerIsHidden) } @@ -1526,12 +3701,24 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugPressStackCornerButton() } + func debugPerformFirstCardMenuItem(titled title: String) { + cardViews.first?.debugPerformMenuItem(titled: title) + } + + func debugPerformFirstCardCaptureRuleMenuItem(titled title: String) { + cardViews.first?.debugPerformCaptureRuleMenuItem(titled: title) + } + var debugResultCountText: String { - statusResultCountLabel.stringValue + currentResultCountText } var debugStatusText: String { - statusLabel.stringValue + currentStatusText + } + + var debugStatusBarIsVisible: Bool { + false } var debugStatusTone: String { @@ -1539,11 +3726,27 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var debugCollectionTitles: [String] { - ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.titleText } + configuredSortModes.compactMap { collectionButtons[$0]?.titleText } } var debugCollectionLeadingSymbols: [String] { - ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.debugLeadingSymbolName } + 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? { @@ -1553,26 +3756,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if let custom = customCollectionButtons.first(where: { $0.value.isSelected }) { return custom.value.titleText } - return collectionButtons.first(where: { $0.value.isSelected })?.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 ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.count } + return configuredSortModes.compactMap { collectionButtons[$0]?.count } } var debugCollectionCountLabelHiddenStates: [Bool] { updateCollectionButtons() - return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.debugCountLabelIsHidden } + return configuredSortModes.compactMap { collectionButtons[$0]?.debugCountLabelIsHidden } } var debugCollectionChipAccessibilityLabels: [String] { updateCollectionButtons() - return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.accessibilityLabel() } + return configuredSortModes.compactMap { collectionButtons[$0]?.accessibilityLabel() } + } + + var debugCollectionChipAccessibilityHelps: [String] { + updateCollectionButtons() + return configuredSortModes.compactMap { collectionButtons[$0]?.accessibilityHelp() } } var debugCollectionChipAcceptsFirstResponder: [Bool] { - ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder } + configuredSortModes.compactMap { collectionButtons[$0]?.acceptsFirstResponder } } var debugKeyboardFocusedCollectionTitles: [String] { @@ -1584,9 +3801,36 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 < cardViews.count else { return false } - return window?.makeFirstResponder(cardViews[index]) ?? false + 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 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() { @@ -1601,6 +3845,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) } @@ -1610,12 +3858,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { updateSearchText() } - private func debugPressFocusedResponder(characters: String, keyCode: UInt16) { + private func debugPressFocusedResponder( + characters: String, + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags = [] + ) { guard let window, let event = NSEvent.keyEvent( with: .keyDown, location: .zero, - modifierFlags: [], + modifierFlags: modifiers, timestamp: 0, windowNumber: window.windowNumber, context: nil, @@ -1666,32 +3918,126 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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() + 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() + } + + var debugCategoryMenuTitles: [String] { + categoryMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } + } + + func debugPerformCategoryMenuItem(_ mode: ClipboardSortMode, extending: Bool = false) { + if extending { + viewModel.selectSortMode(mode, extending: true) + return + } + let menu = categoryMenu() + guard let item = menu.items.first(where: { $0.representedObject as? Int == mode.rawValue }) else { return } + _ = item.target?.perform(item.action, with: item) + } + + 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.scrollHorizontallyByVerticalDelta(deltaY) + collectionScrollView.scrollVerticallyByVerticalDelta(deltaY) } var debugEmptyStateText: (title: String, detail: String)? { @@ -1710,20 +4056,59 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionNameProviderForTesting = provider } + func debugSetTextClipProvider(_ provider: @escaping () -> String?) { + textClipProviderForTesting = 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 debugCreateTextClipFromToolbar() { + createTextClipFromToolbar() + } + 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) { @@ -1740,21 +4125,103 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var debugSearchFieldWidth: CGFloat { - searchFieldWidthConstraint?.constant ?? searchField.frame.width + searchFieldWidthConstraint?.constant ?? searchControlContainer.frame.width + } + + var debugSearchControlHeight: CGFloat { + searchControlContainer.frame.height + } + + var debugSearchIconButtonHeight: CGFloat { + min(searchIconButton.frame.height, searchControlContainer.bounds.height) + } + + 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 debugSearchFilterMenuPresentationCount: Int { + 0 + } + + func debugSuppressSearchFilterMenuPresentation(_ suppress: Bool = true) { + } + + var debugSearchFilterMenuTitles: [String] { + searchFilterMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } + } + + func debugSearchFilterSubmenuTitles(named title: String) -> [String] { + searchFilterMenu().items.first { $0.title == title }?.submenu?.items.map { + $0.isSeparatorItem ? "-" : $0.title + } ?? [] + } + + func debugSearchFilterSubmenuTokens(named title: String, now: Date) -> [String] { + searchFilterMenu(now: now).items.first { $0.title == title }?.submenu?.items.compactMap { + $0.representedObject as? String + } ?? [] + } + + func debugPerformSearchFilterMenuItem(titled title: String, inSubmenu submenuTitle: String? = nil) { + let menu = searchFilterMenu() + let item: NSMenuItem? + if let submenuTitle { + item = menu.items.first { $0.title == submenuTitle }?.submenu?.items.first { $0.title == title } + } else { + item = menu.items.first { $0.title == title } + } + guard let item else { return } + _ = item.target?.perform(item.action, with: item) + } + + func debugApplySearchFilterToken(_ token: String) { + applySearchFilterToken(token) + } + var debugShelfChromeRowCount: Int { headerStack?.arrangedSubviews.count ?? 0 } var debugShelfChromeContainsSearchAndCollections: Bool { - guard let arrangedSubviews = shelfChromeStack?.arrangedSubviews else { return false } - return arrangedSubviews.contains { $0 === searchField } - && arrangedSubviews.contains { $0 === collectionScrollView } + 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) { @@ -1762,6 +4229,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 } } @@ -1775,12 +4247,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { func controlTextDidBeginEditing(_ notification: Notification) { guard notification.object as? NSSearchField === searchField else { return } - updateSearchFieldPresentation() + searchFieldPresentationRequested = true + searchFieldCollapsedWhileIdle = false + searchField.setKeyboardFocusForPresentation(true) } func controlTextDidEndEditing(_ notification: Notification) { guard notification.object as? NSSearchField === searchField else { return } - updateSearchFieldPresentation() + DispatchQueue.main.async { [weak self] in + guard let self, !self.isSearchFieldEditing else { return } + self.searchFieldPresentationRequested = false + self.searchFieldCollapsedWhileIdle = self.searchField.stringValue.isEmpty + self.searchField.setKeyboardFocusForPresentation(false) + self.updateSearchFieldPresentation() + } } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { @@ -1798,9 +4278,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } return true case #selector(NSResponder.moveUp(_:)): + claimKeyboardCardSelection() viewModel.moveSelection(-1) return true case #selector(NSResponder.moveDown(_:)): + claimKeyboardCardSelection() viewModel.moveSelection(1) return true default: @@ -1812,71 +4294,365 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { updateSearchText() } + #if DEBUG + @objc private func applySearchFilterFromMenu(_ sender: NSMenuItem) { + guard let token = sender.representedObject as? String else { return } + applySearchFilterToken(token) + } + #endif + private func updateSearchText() { + if !searchField.stringValue.isEmpty { + searchFieldPresentationRequested = true + searchFieldCollapsedWhileIdle = false + } else if !(searchField.hasKeyboardFocusForPresentation && isSearchFieldEditing) { + searchFieldPresentationRequested = false + searchFieldCollapsedWhileIdle = true + if searchField.hasKeyboardFocusForPresentation { + searchField.setKeyboardFocusForPresentation(false) + } + } viewModel.searchText = searchField.stringValue updateSearchFieldPresentation() } + @objc private func focusSearchFromIcon() { + focusSearchField() + } + private func moveSelectionFromFocusedCard(_ delta: Int) { + claimKeyboardCardSelection() viewModel.moveSelection(delta) focusSelectedCard() } + private func extendSelectionFromFocusedCard(_ delta: Int) { + let count = viewModel.visibleItems.count + guard count > 0 else { return } + claimKeyboardCardSelection() + let target = max(0, min(count - 1, viewModel.selectedIndex + delta)) + viewModel.selectItem(at: target, mode: .range) + focusSelectedCard() + } + private func selectFirstCardFromFocusedCard() { + claimKeyboardCardSelection() viewModel.selectFirstItem() focusSelectedCard() } private func selectLastCardFromFocusedCard() { + claimKeyboardCardSelection() viewModel.selectLastItem() focusSelectedCard() } - private func focusSelectedCard() { - guard viewModel.selectedIndex >= 0, - viewModel.selectedIndex < cardViews.count else { - return - } - window?.makeFirstResponder(cardViews[viewModel.selectedIndex]) + private func extendSelectionToFirstCardFromFocusedCard() { + claimKeyboardCardSelection() + viewModel.selectItem(at: 0, mode: .range) + focusSelectedCard() } - @objc private func closePanel() { - onClose() + private func extendSelectionToLastCardFromFocusedCard() { + claimKeyboardCardSelection() + viewModel.selectItem(at: viewModel.visibleItems.count - 1, mode: .range) + focusSelectedCard() + } + + private func claimKeyboardCardSelection() { + selectionScrollSuppressionCount = 0 + cardSelectionInputSource = .keyboard + hoverSelectionRequiresFreshMouseMovement = true + hoverSelectionKeyboardBarrierLocation = window?.mouseLocationOutsideOfEventStream + clearCardHoverStates() + } + + private func claimMouseHoverSelection(at index: Int, mouseLocation: NSPoint?) -> Bool { + if hoverSelectionRequiresFreshMouseMovement { + guard let location = mouseLocation else { return false } + if let barrier = hoverSelectionKeyboardBarrierLocation, + abs(location.x - barrier.x) < 0.5, + abs(location.y - barrier.y) < 0.5 { + return false + } + hoverSelectionRequiresFreshMouseMovement = false + hoverSelectionKeyboardBarrierLocation = nil + } + cardSelectionInputSource = .mouse + selectionScrollSuppressionCount = 3 + hoveredCardIndex = index + clearCardHoverStates(except: index) + return true + } + + private func clearCardHoverStates(except retainedIndex: Int? = nil) { + let previousHoveredIndex = hoveredCardIndex + if previousHoveredIndex != retainedIndex { + hoveredCardIndex = retainedIndex + } + for card in cardViews where card.representedIndex != retainedIndex { + card.setHoverState(false, notifySelection: false) + } + } + + private func handleCardHoverExit(at index: Int) { + guard hoveredCardIndex == index else { return } + hoveredCardIndex = nil + refreshRenderedCardPresentationForHoverChange() + } + + private func refreshRenderedCardPresentationForHoverChange() { + guard currentPanelLayout == .vertical, cardItemCount > 0 else { return } + let shouldAnimate = window != nil + for card in cardViews { + applyCurrentCardState(card, index: card.representedIndex, animated: shouldAnimate) + } + sizeItemsDocument(itemCount: cardItemCount, animated: shouldAnimate) + renderCardsNearVisibleViewport() + } + + private func focusSelectedCard() { + guard viewModel.selectedIndex >= 0, + viewModel.selectedIndex < cardItemCount else { + window?.makeFirstResponder(self) + return + } + renderCardIfNeeded(at: viewModel.selectedIndex) + guard let card = cardView(at: viewModel.selectedIndex) else { + window?.makeFirstResponder(self) + return + } + window?.makeFirstResponder(card) } @objc private func openSettings() { onSettings() } + @objc private func showToolbarMenu(_ sender: NSButton) { + let menu = sender === categoryMenuButton ? categoryMenu() : clearHistoryMenu() + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.maxY + 4), in: sender) + } + + @objc private func performToolbarMenuItem(_ sender: NSMenuItem) { + if let rawValue = sender.representedObject as? Int, + let mode = ClipboardSortMode(rawValue: rawValue) { + collapseSearchFieldIfIdle() + viewModel.selectSortMode(mode) + return + } + let seconds = (sender.representedObject as? NSNumber)?.doubleValue + ?? (sender.representedObject as? TimeInterval) + guard let seconds else { return } + viewModel.clearHistory(since: seconds < 0 ? .distantPast : Date().addingTimeInterval(-seconds)) + } + + @objc private func toggleCompactMode() { + viewModel.toggleCompactMode() + } + func showSelectedInClipboard() { showSelectedInClipboard(at: viewModel.selectedIndex) } private func showSelectedInClipboard(at index: Int) { + #if DEBUG + showInClipboardRequestCountForTesting += 1 + #endif viewModel.selectItem(at: index) viewModel.showSelectedInClipboard() - searchField.stringValue = viewModel.searchText - updateSearchFieldPresentation() + syncSearchFieldFromViewModel() + } + + private func syncSearchFieldFromViewModel() { + let searchText = viewModel.searchText + if searchField.stringValue != searchText { + searchField.stringValue = searchText + } + let preservesFocusedEmptySearch = searchField.hasKeyboardFocusForPresentation && isSearchFieldEditing + let shouldCollapseEmptySearch = searchText.isEmpty && !preservesFocusedEmptySearch + if searchText.isEmpty { + if shouldCollapseEmptySearch { + searchFieldPresentationRequested = false + searchFieldCollapsedWhileIdle = true + if searchField.hasKeyboardFocusForPresentation { + searchField.setKeyboardFocusForPresentation(false) + } + } + } else { + searchFieldPresentationRequested = true + searchFieldCollapsedWhileIdle = false + } + updateSearchFieldPresentation(animated: !shouldCollapseEmptySearch) } func createCollection() { createCollectionFromToolbar() } + func createTextClip() { + createTextClipFromToolbar() + } + + @objc private func createTextClipFromToolbar() { + guard let text = requestTextClipCreation() else { return } + guard viewModel.createTextClip(text) != nil else { return } + syncSearchFieldFromViewModel() + focusSelectedCard() + } + @objc private func createCollectionFromToolbar() { guard let request = requestCollectionCreation() else { return } viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true) + focusSelectedCollectionChip() } private func editCollection(named collectionName: String) { guard let request = requestCollectionEdit(named: collectionName) else { return } viewModel.updateCollection(named: collectionName, to: request.name, colorHex: request.colorHex) + focusSelectedCollectionChip() + } + + private func exportCollection(named collectionName: String) { + let panel = NSSavePanel() + panel.title = "Export \(collectionName) Pinboard" + panel.nameFieldStringValue = defaultCollectionArchiveFileName(collectionName) + configureArchiveFileType(on: panel) + + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + _ = try viewModel.exportCollection(named: collectionName, to: url) + } catch { + viewModel.reportCollectionExportFailure(error) + } } private func deleteCollection(named collectionName: String) { let count = viewModel.collectionCount(named: collectionName) guard confirmDeleteCollection(named: collectionName, count: count) else { return } viewModel.deleteCollection(named: collectionName) + focusSelectedCollectionChip() + } + + private func defaultCollectionArchiveFileName(_ collectionName: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmm" + let safeName = collectionName + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + let name = safeName.isEmpty ? "Pinboard" : safeName + return "ClipBored-\(name)-\(formatter.string(from: Date())).\(ClipboardArchiveService.fileExtension)" + } + + private func requestTextClipCreation() -> String? { + #if DEBUG + if let textClipProviderForTesting { + return textClipProviderForTesting() + } + #endif + + let textView = Self.makePlainTextEditor() + let accessoryView = textEditorAccessoryView(for: textView) + + let alert = NSAlert() + alert.messageText = "New Text Clip" + alert.accessoryView = accessoryView + alert.addButton(withTitle: "Create") + alert.addButton(withTitle: "Cancel") + alert.window.initialFirstResponder = textView + activeWritingToolsTextView = textView + defer { + if activeWritingToolsTextView === textView { + activeWritingToolsTextView = nil + } + } + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + return textView.string + } + + static func makePlainTextEditor(initialText: String = "") -> NSTextView { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 460, height: 180)) + configurePlainTextEditor(textView, initialText: initialText) + return textView + } + + static func configurePlainTextEditor(_ textView: NSTextView, initialText: String = "") { + textView.string = initialText + textView.font = .systemFont(ofSize: 13) + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.textContainerInset = NSSize(width: 10, height: 10) + textView.usesAdaptiveColorMappingForDarkAppearance = true + textView.isAutomaticSpellingCorrectionEnabled = true + textView.isAutomaticTextReplacementEnabled = true + + if textView.responds(to: NSSelectorFromString("setWritingToolsBehavior:")) { + textView.setValue(1, forKey: "writingToolsBehavior") + } + if textView.responds(to: NSSelectorFromString("setAllowedWritingToolsResultOptions:")) { + textView.setValue(1, forKey: "allowedWritingToolsResultOptions") + } + } + + private func textEditorAccessoryView(for textView: NSTextView) -> NSView { + let scrollView = NSScrollView(frame: textView.frame) + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .bezelBorder + scrollView.documentView = textView + scrollView.widthAnchor.constraint(equalToConstant: 460).isActive = true + scrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true + + guard Self.supportsWritingTools(textView) else { + return scrollView + } + + let writingToolsButton = NSButton( + title: "Writing Tools", + target: self, + action: #selector(showWritingToolsForActiveEditor(_:)) + ) + writingToolsButton.bezelStyle = .rounded + writingToolsButton.setAccessibilityLabel("Writing Tools") + writingToolsButton.toolTip = "Open macOS Writing Tools for this text clip." + + let note = NSTextField(labelWithString: "Uses macOS Writing Tools when available.") + note.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + note.textColor = .secondaryLabelColor + + let row = NSStackView(views: [writingToolsButton, note]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 10 + + let stack = NSStackView(views: [scrollView, row]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.frame = NSRect(x: 0, y: 0, width: 460, height: 218) + return stack + } + + static func supportsWritingTools(_ textView: NSTextView) -> Bool { + textView.responds(to: NSSelectorFromString("showWritingTools:")) + } + + @objc private func showWritingToolsForActiveEditor(_ sender: NSButton) { + guard let textView = activeWritingToolsTextView, + Self.supportsWritingTools(textView) + else { + return + } + textView.window?.makeFirstResponder(textView) + NSApp.sendAction(NSSelectorFromString("showWritingTools:"), to: textView, from: sender) + } + + private func configureArchiveFileType(on panel: NSSavePanel) { + panel.setValue([ClipboardArchiveService.fileExtension], forKey: "allowedFileTypes") } private func requestCollectionCreation() -> CollectionCreationRequest? { @@ -1996,6 +4772,17 @@ private func shelfSearchText(from event: NSEvent) -> String? { return characters } +private func selectionMode(from modifiers: NSEvent.ModifierFlags) -> ClipboardSelectionMode { + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + if relevantModifiers.contains(.shift) { + return .range + } + if relevantModifiers.contains(.command) { + return .toggle + } + return .replace +} + private enum ClipboardCardDragContext { static var itemID: UUID? } @@ -2003,14 +4790,27 @@ private enum ClipboardCardDragContext { private final class HorizontalRailScrollView: NSScrollView { private let leadingFade = RailEdgeFadeView(edge: .leading) private let trailingFade = RailEdgeFadeView(edge: .trailing) - private let fadeWidth: CGFloat = 26 + private let fadeWidth: CGFloat + var onVisibleBoundsChanged: (() -> Void)? + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) + } + + init(edgeFadeWidth: CGFloat = 26) { + self.fadeWidth = edgeFadeWidth + super.init(frame: .zero) + configureOverflowFades() + } override init(frame frameRect: NSRect) { + self.fadeWidth = 26 super.init(frame: frameRect) configureOverflowFades() } required init?(coder: NSCoder) { + self.fadeWidth = 26 super.init(coder: coder) configureOverflowFades() } @@ -2032,22 +4832,32 @@ private final class HorizontalRailScrollView: NSScrollView { override func layout() { super.layout() updateOverflowFades() + onVisibleBoundsChanged?() } override func reflectScrolledClipView(_ clipView: NSClipView) { super.reflectScrolledClipView(clipView) updateOverflowFades() + 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 @@ -2076,6 +4886,11 @@ 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 } @@ -2087,6 +4902,17 @@ 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 { @@ -2101,6 +4927,25 @@ private final class HorizontalRailScrollView: NSScrollView { } } +private final class ClipboardItemsDocumentView: NSStackView { + var usesFlippedCoordinates = false { + didSet { + guard oldValue != usesFlippedCoordinates else { return } + needsLayout = true + } + } + + override var isFlipped: Bool { + usesFlippedCoordinates + } +} + +private final class CollectionRailDocumentView: NSView { + override var isFlipped: Bool { + true + } +} + private final class RailEdgeFadeView: NSView { enum Edge { case leading @@ -2132,37 +4977,74 @@ private final class RailEdgeFadeView: NSView { } } +private final class TextPreviewBottomFadeView: NSView { + private let color: NSColor + + init(color: NSColor) { + self.color = color + super.init(frame: .zero) + wantsLayer = true + setAccessibilityElement(false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func draw(_ dirtyRect: NSRect) { + let clear = color.withAlphaComponent(0) + let solid = color.withAlphaComponent(0.96) + NSGradient(colors: [clear, solid])?.draw(in: bounds, angle: -90) + } +} + private final class CollectionChipView: NSView { + private enum Metrics { + static let iconOnlySize: CGFloat = 34 + } + let titleText: String private let color: NSColor private let symbolName: String? + private let iconOnly: Bool private let dot = NSView() private let symbolView = NSImageView() private let label: NSTextField private let countLabel = NSTextField(labelWithString: "0") private(set) var isSelected = false private(set) var count = 0 + private var isStackCaptureActive = false private var isKeyboardFocused = false private var isDropTargeted = false - var onPress: () -> Void = {} + private var trackingAreaRef: NSTrackingArea? + var onPress: (Bool) -> Void = { _ in } + var onHover: (Bool) -> Void = { _ in } var onStartSearch: (String) -> Void = { _ in } var onMoveFocus: (Int) -> Void = { _ in } + var onMoveCardSelection: (Int) -> Void = { _ in } var onSelectFirst: () -> Void = {} var onSelectLast: () -> Void = {} var onDropItem: ((UUID) -> Void)? - var onAddVisibleToStack: (() -> Void)? - var onPasteStackNext: (() -> Void)? - var onCopyStackNext: (() -> Void)? - var onPasteStackText: (() -> Void)? - var onCopyStackText: (() -> Void)? - var onClearStack: (() -> Void)? - var onEdit: (() -> Void)? - var onDelete: (() -> Void)? + var onAddVisibleToStack: (() -> Void)? { didSet { updateAccessibility() } } + var onPasteStackNext: (() -> Void)? { didSet { updateAccessibility() } } + var onCopyStackNext: (() -> Void)? { didSet { updateAccessibility() } } + var onPasteStackText: (() -> Void)? { didSet { updateAccessibility() } } + var onCopyStackText: (() -> Void)? { didSet { updateAccessibility() } } + var onClearStack: (() -> Void)? { didSet { updateAccessibility() } } + var onToggleStackCapture: (() -> Void)? { didSet { updateAccessibility() } } + var onEdit: (() -> Void)? { didSet { updateAccessibility() } } + var onExport: (() -> Void)? { didSet { updateAccessibility() } } + var onDelete: (() -> Void)? { didSet { updateAccessibility() } } - init(title: String, color: NSColor, symbolName: String? = nil) { + init(title: String, color: NSColor, symbolName: String? = nil, iconOnly: Bool = false) { self.titleText = title self.color = color self.symbolName = symbolName + self.iconOnly = iconOnly self.label = NSTextField(labelWithString: title) super.init(frame: .zero) configure() @@ -2175,20 +5057,24 @@ private final class CollectionChipView: NSView { private func configure() { wantsLayer = true focusRingType = .default - layer?.cornerRadius = 13 - layer?.borderWidth = 0.6 + layer?.cornerRadius = 14 + layer?.borderWidth = 0.5 layer?.borderColor = NSColor.clear.cgColor setAccessibilityElement(true) setAccessibilityRole(.button) - setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.") - heightAnchor.constraint(equalToConstant: 26).isActive = true + setAccessibilityHelp(accessibilityHelpText()) + heightAnchor.constraint(equalToConstant: iconOnly ? Metrics.iconOnlySize : 28).isActive = true + if iconOnly { + widthAnchor.constraint(equalToConstant: Metrics.iconOnlySize).isActive = true + layer?.cornerRadius = Metrics.iconOnlySize / 2 + } registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes) dot.wantsLayer = true - dot.layer?.cornerRadius = 4 + dot.layer?.cornerRadius = iconOnly ? 7 : 4 dot.layer?.backgroundColor = color.cgColor - dot.widthAnchor.constraint(equalToConstant: 8).isActive = true - dot.heightAnchor.constraint(equalToConstant: 8).isActive = true + dot.widthAnchor.constraint(equalToConstant: iconOnly ? 14 : 8).isActive = true + dot.heightAnchor.constraint(equalToConstant: iconOnly ? 14 : 8).isActive = true let leadingIndicator: NSView if let symbolName { @@ -2197,18 +5083,19 @@ private final class CollectionChipView: NSView { symbolView.image = image symbolView.imageScaling = .scaleProportionallyUpOrDown symbolView.contentTintColor = color.withAlphaComponent(0.86) - symbolView.widthAnchor.constraint(equalToConstant: 13).isActive = true - symbolView.heightAnchor.constraint(equalToConstant: 13).isActive = true + symbolView.widthAnchor.constraint(equalToConstant: iconOnly ? 18 : 13).isActive = true + symbolView.heightAnchor.constraint(equalToConstant: iconOnly ? 18 : 13).isActive = true leadingIndicator = symbolView } else { leadingIndicator = dot } - label.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) label.textColor = .secondaryLabelColor label.lineBreakMode = .byTruncatingTail label.maximumNumberOfLines = 1 - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.isHidden = iconOnly + label.setContentCompressionResistancePriority(.required, for: .horizontal) label.setContentHuggingPriority(.defaultHigh, for: .horizontal) label.toolTip = label.stringValue @@ -2218,36 +5105,44 @@ private final class CollectionChipView: NSView { countLabel.lineBreakMode = .byTruncatingTail countLabel.wantsLayer = true countLabel.layer?.cornerRadius = 8 - countLabel.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.07).cgColor + countLabel.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.055).cgColor countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 18).isActive = true countLabel.heightAnchor.constraint(equalToConstant: 16).isActive = true + countLabel.isHidden = true countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - let stack = NSStackView(views: [leadingIndicator, label, countLabel]) + let stack = NSStackView(views: iconOnly ? [leadingIndicator] : [leadingIndicator, label, countLabel]) stack.orientation = .horizontal stack.alignment = .centerY - stack.spacing = 6 + stack.spacing = iconOnly ? 0 : 7 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), - stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - stack.centerYAnchor.constraint(equalTo: centerYAnchor), - widthAnchor.constraint(greaterThanOrEqualToConstant: 70), - widthAnchor.constraint(lessThanOrEqualToConstant: 164) - ]) + if iconOnly { + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } else { + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 11), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -11), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + widthAnchor.constraint(greaterThanOrEqualToConstant: symbolName == nil ? 70 : 84), + widthAnchor.constraint(lessThanOrEqualToConstant: 164) + ]) + } setSelected(false) } func setSelected(_ selected: Bool) { isSelected = selected label.textColor = selected ? .labelColor : .secondaryLabelColor - countLabel.textColor = selected ? .labelColor : .tertiaryLabelColor + countLabel.textColor = selected ? color.withAlphaComponent(0.95) : .tertiaryLabelColor countLabel.layer?.backgroundColor = ( selected - ? color.withAlphaComponent(0.16) - : NSColor.labelColor.withAlphaComponent(0.07) + ? NSColor.windowBackgroundColor.withAlphaComponent(0.55) + : NSColor.labelColor.withAlphaComponent(0.055) ).cgColor symbolView.contentTintColor = color.withAlphaComponent(selected ? 0.98 : 0.78) updateCountLabelVisibility() @@ -2263,14 +5158,17 @@ private final class CollectionChipView: NSView { private func updateChrome() { if isDropTargeted { - layer?.backgroundColor = color.withAlphaComponent(0.18).cgColor - layer?.borderColor = color.withAlphaComponent(0.68).cgColor + layer?.backgroundColor = color.withAlphaComponent(0.16).cgColor + layer?.borderColor = color.withAlphaComponent(0.54).cgColor } else if isSelected { - layer?.backgroundColor = color.withAlphaComponent(0.13).cgColor - layer?.borderColor = color.withAlphaComponent(isKeyboardFocused ? 0.72 : 0.38).cgColor + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.48).cgColor + layer?.borderColor = color.withAlphaComponent(isKeyboardFocused ? 0.52 : 0.24).cgColor + } else if isStackCaptureActive { + layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor + layer?.borderColor = color.withAlphaComponent(isKeyboardFocused ? 0.46 : 0.28).cgColor } else if isKeyboardFocused { - layer?.backgroundColor = color.withAlphaComponent(0.09).cgColor - layer?.borderColor = color.withAlphaComponent(0.44).cgColor + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor + layer?.borderColor = color.withAlphaComponent(0.34).cgColor } else { layer?.backgroundColor = NSColor.clear.cgColor layer?.borderColor = NSColor.clear.cgColor @@ -2284,61 +5182,110 @@ private final class CollectionChipView: NSView { updateAccessibility() } + func setStackCaptureActive(_ active: Bool) { + isStackCaptureActive = active + updateChrome() + updateAccessibility() + } + private func updateCountLabelVisibility() { - countLabel.isHidden = count == 0 + countLabel.isHidden = iconOnly || count == 0 } private func updateAccessibility() { let noun = count == 1 ? "clip" : "clips" let selectedText = isSelected ? "selected, " : "" - setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)") + let captureText = isStackCaptureActive ? "capture on, " : "" + setAccessibilityLabel("\(titleText), \(selectedText)\(captureText)\(count) \(noun)") setAccessibilityValue("\(count)") - setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.") + setAccessibilityHelp(accessibilityHelpText()) toolTip = "\(titleText), \(selectedText)\(count) \(noun)" } + private func accessibilityHelpText() -> String { + var parts = [ + "Press Return or Space to show \(titleText). Use Up/Down to move between categories, Left/Right to move between clips, and Home/End to move between categories." + ] + if hasStackMenuActions { + parts.append("Open the context menu for Stack capture and Stack paste actions.") + } + if hasCollectionManagementActions { + parts.append("Open the context menu to edit, export, or delete this Pinboard.") + } + return parts.joined(separator: " ") + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { - isKeyboardFocused = true - updateChrome() + setKeyboardFocusForPresentation(true) return true } override func resignFirstResponder() -> Bool { - isKeyboardFocused = false - updateChrome() + setKeyboardFocusForPresentation(false) return true } - func clearKeyboardFocus() { - guard isKeyboardFocused else { return } - isKeyboardFocused = false + func setKeyboardFocusForPresentation(_ focused: Bool) { + guard isKeyboardFocused != focused else { return } + isKeyboardFocused = focused updateChrome() } + func clearKeyboardFocus() { + setKeyboardFocusForPresentation(false) + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingAreaRef { + removeTrackingArea(trackingAreaRef) + } + let tracking = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(tracking) + trackingAreaRef = tracking + } + + override func mouseEntered(with event: NSEvent) { + onHover(true) + } + + override func mouseExited(with event: NSEvent) { + onHover(false) + } + override func mouseDown(with event: NSEvent) { - onPress() + activateFromUserInteraction(extending: event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command)) } override func keyDown(with event: NSEvent) { switch event.keyCode { case 36, 49: - onPress() + onPress(false) + case 125: + onMoveFocus(1) + case 126: + onMoveFocus(-1) case 115: onSelectFirst() case 119: onSelectLast() case 123: - onMoveFocus(-1) + onMoveCardSelection(-1) case 124: - onMoveFocus(1) + onMoveCardSelection(1) default: if let text = shelfSearchText(from: event) { onStartSearch(text) @@ -2349,29 +5296,58 @@ private final class CollectionChipView: NSView { } override func accessibilityPerformPress() -> Bool { - onPress() + activateFromUserInteraction(extending: false) return true } + private func activateFromUserInteraction(extending: Bool) { + window?.makeFirstResponder(self) + onPress(extending) + } + override func menu(for event: NSEvent) -> NSMenu? { guard hasContextMenuActions else { return nil } return contextMenu() } private var hasContextMenuActions: Bool { + hasStackMenuActions || hasCollectionManagementActions + } + + private var hasStackMenuActions: Bool { onAddVisibleToStack != nil || onPasteStackNext != nil || onCopyStackNext != nil || onPasteStackText != nil || onCopyStackText != nil || onClearStack != nil - || onEdit != nil + || onToggleStackCapture != nil + } + + private var hasCollectionManagementActions: Bool { + onEdit != nil + || onExport != nil || onDelete != nil } private func contextMenu() -> NSMenu { 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) + } + if onToggleStackCapture != nil && ( + onAddVisibleToStack != nil + || onPasteStackNext != nil + || onCopyStackNext != nil + || onPasteStackText != nil + || onCopyStackText != nil + || onClearStack != nil + ) { + menu.addItem(NSMenuItem.separator()) + } if onAddVisibleToStack != nil { let item = NSMenuItem(title: "Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), keyEquivalent: "") item.target = self @@ -2402,7 +5378,7 @@ private final class CollectionChipView: NSView { item.target = self menu.addItem(item) } - if (onEdit != nil || onDelete != nil) && !menu.items.isEmpty { + if (onEdit != nil || onExport != nil || onDelete != nil) && !menu.items.isEmpty { menu.addItem(NSMenuItem.separator()) } if onEdit != nil { @@ -2410,6 +5386,11 @@ private final class CollectionChipView: NSView { item.target = self menu.addItem(item) } + if onExport != nil { + let item = NSMenuItem(title: "Export Pinboard...", action: #selector(exportFromMenu), keyEquivalent: "") + item.target = self + menu.addItem(item) + } if onDelete != nil { if !menu.items.isEmpty { menu.addItem(NSMenuItem.separator()) @@ -2445,10 +5426,18 @@ private final class CollectionChipView: NSView { onClearStack?() } + @objc private func toggleStackCaptureFromMenu() { + onToggleStackCapture?() + } + @objc private func editFromMenu() { onEdit?() } + @objc private func exportFromMenu() { + onExport?() + } + @objc private func deleteFromMenu() { onDelete?() } @@ -2510,14 +5499,34 @@ private final class CollectionChipView: NSView { countLabel.isHidden } + var debugLabelIsHidden: Bool { + label.isHidden + } + var debugLeadingSymbolName: String { symbolName ?? "" } + var debugFrameWidth: CGFloat { + frame.width + } + func debugDropItem(_ itemID: UUID) { onDropItem?(itemID) } + func debugMouseDown() { + activateFromUserInteraction(extending: false) + } + + func debugCommandMouseDown() { + activateFromUserInteraction(extending: true) + } + + func debugSetHovered(_ hovered: Bool) { + onHover(hovered) + } + var debugMenuTitles: [String] { contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } } @@ -2573,6 +5582,137 @@ private final class AspectFillImageView: NSView { } } +private final class HeaderBadgeTileView: NSView { + private let tileCornerRadius: CGFloat + private let tileMaskedCorners: CACornerMask + + init(cornerRadius: CGFloat, maskedCorners: CACornerMask) { + self.tileCornerRadius = cornerRadius + self.tileMaskedCorners = maskedCorners + super.init(frame: .zero) + wantsLayer = true + applyChrome() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + applyChrome() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + applyChrome() + } + + private func applyChrome() { + wantsLayer = true + guard let layer else { return } + layer.masksToBounds = false + layer.cornerRadius = tileCornerRadius + layer.maskedCorners = tileMaskedCorners + layer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.74).cgColor + layer.borderWidth = 1 + layer.borderColor = NSColor.white.withAlphaComponent(0.44).cgColor + layer.shadowColor = NSColor.black.cgColor + layer.shadowOpacity = 0.14 + layer.shadowRadius = 8 + layer.shadowOffset = NSSize(width: 0, height: 2) + } +} + +private final class ClipboardItemCardSlotView: NSView { + fileprivate private(set) var card: ClipboardItemCardView? + private let itemID: UUID + private var topConstraint: NSLayoutConstraint? + private let inactiveTopOffset: CGFloat + private var isLifted = false + + init(itemID: UUID, layout: ClipboardItemCardLayout) { + self.itemID = itemID + inactiveTopOffset = layout.activeLift + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = true + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.zPosition = 0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isFlipped: Bool { + true + } + + fileprivate var representedItemID: UUID { + itemID + } + + func attachCard(_ card: ClipboardItemCardView) { + guard self.card !== card else { return } + self.card?.removeFromSuperview() + topConstraint?.isActive = false + self.card = card + card.translatesAutoresizingMaskIntoConstraints = false + addSubview(card) + let topConstraint = card.topAnchor.constraint(equalTo: topAnchor, constant: isLifted ? 0 : inactiveTopOffset) + self.topConstraint = topConstraint + NSLayoutConstraint.activate([ + card.leadingAnchor.constraint(equalTo: leadingAnchor), + card.trailingAnchor.constraint(equalTo: trailingAnchor), + topConstraint + ]) + } + + func setLifted(_ lifted: Bool) { + guard isLifted != lifted else { return } + isLifted = lifted + let targetOffset = lifted ? 0 : inactiveTopOffset + layer?.zPosition = lifted ? 20 : 10 + if window == nil { + topConstraint?.constant = targetOffset + layer?.zPosition = lifted ? 20 : 0 + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.cardLiftDuration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + topConstraint?.constant = targetOffset + superview?.animator().layoutSubtreeIfNeeded() + animator().layoutSubtreeIfNeeded() + } completionHandler: { [weak self] in + guard let self else { return } + if !lifted { + self.layer?.zPosition = 0 + } + } + } + + #if DEBUG + var debugCardTopOffset: CGFloat { + topConstraint?.constant ?? inactiveTopOffset + } + + var debugZPosition: CGFloat { + CGFloat(layer?.zPosition ?? 0) + } + + var debugPresentationFrame: NSRect { + layer?.presentation()?.frame ?? frame + } + #endif +} + private final class ClipboardItemCardView: NSView, NSDraggingSource { private enum Metrics { static let dragThreshold: CGFloat = 4 @@ -2580,13 +5720,24 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { static let actionRailLeadingMargin: CGFloat = 10 } private enum Palette { - static let border = NSColor.separatorColor.withAlphaComponent(0.20).cgColor - static let selectedBorder = NSColor.controlAccentColor.withAlphaComponent(0.62).cgColor - static let cardSurface = NSColor.windowBackgroundColor.cgColor - static let selectedSurface = NSColor.windowBackgroundColor.cgColor - static let bodyBackground = NSColor.windowBackgroundColor.cgColor - static let footerBackground = NSColor.windowBackgroundColor.withAlphaComponent(0.96).cgColor - static let divider = NSColor.separatorColor.withAlphaComponent(0.14).cgColor + static let border = NSColor.white.withAlphaComponent(0.42).cgColor + static let selectedBorder = NSColor.white.withAlphaComponent(0.74).cgColor + static let cardSurface = NSColor.windowBackgroundColor.withAlphaComponent(0.84).cgColor + static let selectedSurface = NSColor.windowBackgroundColor.withAlphaComponent(0.96).cgColor + static let bodyBackground = NSColor.windowBackgroundColor.withAlphaComponent(0.82).cgColor + static let footerBackground = NSColor.windowBackgroundColor.withAlphaComponent(0.74).cgColor + static let divider = NSColor.white.withAlphaComponent(0.14).cgColor + static let actionRailBackground = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor + static let actionRailBorder = NSColor.white.withAlphaComponent(0.28).cgColor + static let secondaryActionBackground = NSColor.labelColor.withAlphaComponent(0.08).cgColor + } + private enum CornerMasks { + static let topOnly: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + static let badgeWithoutBottomRight: CACornerMask = [ + .layerMinXMinYCorner, + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] } private struct ActionRailButtonSpec { @@ -2611,15 +5762,25 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } } - var onSelect: (Int) -> Void = { _ in } + var onSelect: (Int, ClipboardSelectionMode) -> Void = { _, _ in } + var onHover: (Int, NSPoint?) -> Bool = { _, _ in true } + var onHoverExit: (Int) -> Void = { _ in } var onMoveSelection: (Int) -> Void = { _ in } + var onExtendSelection: (Int) -> Void = { _ in } var onPageSelection: (Int) -> Void = { _ in } + var onPageExtendSelection: (Int) -> Void = { _ in } var onSelectFirst: () -> Void = {} var onSelectLast: () -> Void = {} + var onExtendSelectionToFirst: () -> Void = {} + var onExtendSelectionToLast: () -> Void = {} + var onSelectAll: () -> Void = {} var onPaste: (Int) -> Void = { _ in } var onCopy: (Int) -> Void = { _ in } var onPastePlainText: (Int) -> Void = { _ in } var onCopyPlainText: (Int) -> Void = { _ in } + var onPasteSelectionText: (Int) -> Void = { _ in } + var onCopySelectionText: (Int) -> Void = { _ in } + var onAddSelectionToStack: (Int) -> Void = { _ in } var onToggleStack: (Int) -> Void = { _ in } var onAddVisibleToStack: () -> Void = {} var onPasteStackNext: () -> Void = {} @@ -2630,6 +5791,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onShowInClipboard: (Int) -> Void = { _ in } var onRename: (Int) -> Void = { _ in } var onEditText: (Int) -> Void = { _ in } + var onRotateImage: (Int) -> Void = { _ in } + var onExtractImageText: (Int) -> Void = { _ in } var onPreview: (Int) -> Void = { _ in } var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } var onOpen: (Int) -> Void = { _ in } @@ -2639,15 +5802,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onIgnoreSourceApp: (Int) -> Void = { _ in } var onIgnoreKind: (Int) -> Void = { _ in } var onDelete: (Int) -> Void = { _ in } + var onUndoDelete: () -> Void = {} var onStartSearch: (String) -> Void = { _ in } + var onLiftStateChanged: (Bool) -> Void = { _ in } - private let index: Int + private var index: Int private let itemID: UUID + private let reuseFingerprint: ClipboardItemCardReuseFingerprint private let layout: ClipboardItemCardLayout private let itemKind: ClipboardItemKind private let itemIsPinned: Bool - private let itemIsStacked: Bool - private let stackCount: Int + private var itemIsStacked: Bool + private var stackCount: Int private let canShowInClipboard: Bool private let itemSourceAppName: String? private let itemSourceAppBundleID: String? @@ -2661,19 +5827,56 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private let actionRail = NSStackView() private let stackCornerButton = NSButton() private var actionRailButtons: [NSButton] = [] + private var actionRailWidthConstraint: NSLayoutConstraint? + private var actionRailContentWidth: CGFloat = 0 + private var actionRailIsPresented = false private weak var headerBadgeView: NSView? + private weak var headerBadgeContentView: NSView? private weak var headerPinView: NSView? private weak var quickPasteBadgeLabel: NSTextField? + private var isActiveSelection = false private var isSelected = false + private var selectedGroupCount = 1 private var isHovered = false private var isKeyboardFocused = false private var mouseDownLocation: NSPoint? private var trackingAreaRef: NSTrackingArea? + private var presentation: ClipboardCardPresentation = .expanded + private var presentationAnimationGeneration = 0 + private var widthConstraint: NSLayoutConstraint? + private var heightConstraint: NSLayoutConstraint? + private var expandedDetailHeightConstraint: NSLayoutConstraint? + private var fullContentConstraints: [NSLayoutConstraint] = [] + private var compactContentConstraints: [NSLayoutConstraint] = [] + private weak var fullContentView: NSView? + private weak var compactContentView: NSView? + private weak var compactTextStack: NSView? + private weak var compactMetricLabel: NSTextField? + + fileprivate var representedItemID: UUID { + itemID + } + + fileprivate var representedIndex: Int { + index + } + + fileprivate func canReuse(with fingerprint: ClipboardItemCardReuseFingerprint) -> Bool { + reuseFingerprint == fingerprint + } + + fileprivate func updateIndex(_ index: Int) { + self.index = index + isHovered = false + mouseDownLocation = nil + updateActionRailVisibility() + } init( item: ClipboardItem, thumbnail: NSImage?, index: Int, + reuseFingerprint: ClipboardItemCardReuseFingerprint, layout: ClipboardItemCardLayout = .regular, collectionNames: [String] = [], isStacked: Bool = false, @@ -2687,6 +5890,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let activeCollection = normalizedSelectedCollection == normalizedItemCollection ? normalizedSelectedCollection : nil self.index = index self.itemID = item.id + self.reuseFingerprint = reuseFingerprint self.layout = layout self.itemKind = item.kind self.itemIsPinned = item.isPinned @@ -2710,50 +5914,318 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } func setSelected(_ selected: Bool) { + setSelectionState(active: selected, selected: selected, selectionCount: selected ? 1 : selectedGroupCount) + } + + func setSelectionState(active: Bool, selected: Bool, selectionCount: Int) { + isActiveSelection = active isSelected = selected - contentView.layer?.borderWidth = isKeyboardFocused ? 2 : 1 - if selected { - contentView.layer?.backgroundColor = Palette.selectedSurface - contentView.layer?.borderColor = isKeyboardFocused - ? NSColor.controlAccentColor.withAlphaComponent(0.86).cgColor - : Palette.selectedBorder - } else if isKeyboardFocused { - contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.58).cgColor - } else if isHovered { - contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.28).cgColor - } else { - contentView.layer?.backgroundColor = Palette.cardSurface - contentView.layer?.borderColor = Palette.border + selectedGroupCount = max(1, selectionCount) + let emphasized = active || isKeyboardFocused || isHovered + animateLayerChanges { + self.contentView.layer?.borderWidth = emphasized || selected ? 0.8 : 0.6 + self.contentView.layer?.backgroundColor = self.cardSurfaceColor(emphasized: emphasized, selected: selected) + self.contentView.layer?.borderColor = emphasized || selected ? Palette.selectedBorder : Palette.border + self.applyCardShadow(emphasized: emphasized) + self.layer?.transform = CATransform3DIdentity } - let emphasized = selected || isKeyboardFocused - layer?.shadowOpacity = emphasized ? 0.16 : (isHovered ? 0.12 : 0.08) - layer?.shadowRadius = emphasized ? 16 : 12 - layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 6 : 4) - layer?.transform = emphasized ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity + onLiftStateChanged(emphasized) setAccessibilityValue(selected ? "Selected" : "Not selected") updateActionRailVisibility() } + func setStackState(isStacked: Bool, stackCount: Int) { + guard itemIsStacked != isStacked || self.stackCount != stackCount else { return } + itemIsStacked = isStacked + self.stackCount = stackCount + configureStackCornerButton() + updateActionRailVisibility() + } + + func setPresentation( + _ presentation: ClipboardCardPresentation, + animated: Bool = false, + stableHeaderRollout: Bool = false + ) { + presentationAnimationGeneration += 1 + let animationGeneration = presentationAnimationGeneration + let size = presentation.size(for: layout) + let previousPresentation = self.presentation + let changed = previousPresentation != presentation + self.presentation = presentation + + let isExpanded = presentation.isExpanded + let previousExpanded = previousPresentation.isExpanded + let shouldAnimate = animated && changed && window != nil + let detailHeight = isExpanded ? expandedDetailHeight : 0 + + if shouldAnimate { + fullContentView?.alphaValue = previousExpanded ? 1 : 0 + fullContentView?.isHidden = false + compactContentView?.alphaValue = 1 + compactContentView?.isHidden = false + } + + compactTextStack?.isHidden = presentation == .horizontalIcon + compactMetricLabel?.isHidden = presentation != .verticalRow + let targetCornerRadius = cardCornerRadius(for: presentation, size: size) + stackCornerButton.isHidden = !presentation.showsFloatingActions || !(itemIsStacked || isKeyboardFocused) + + let applyFinalVisibility = { [weak self] in + guard let self else { return } + guard self.presentationAnimationGeneration == animationGeneration else { return } + self.fullContentView?.isHidden = !isExpanded + self.fullContentView?.alphaValue = isExpanded ? 1 : 0 + self.compactContentView?.isHidden = false + self.compactContentView?.alphaValue = 1 + self.widthConstraint?.constant = size.width + self.heightConstraint?.constant = size.height + self.expandedDetailHeightConstraint?.constant = detailHeight + self.contentView.layer?.cornerRadius = targetCornerRadius + self.contentView.layer?.backgroundColor = self.cardSurfaceColor( + emphasized: self.isActiveSelection || self.isKeyboardFocused || self.isHovered, + selected: self.isSelected + ) + self.applyCardShadow(emphasized: self.isActiveSelection || self.isKeyboardFocused || self.isHovered) + self.needsLayout = true + self.layoutSubtreeIfNeeded() + } + + guard shouldAnimate else { + widthConstraint?.constant = size.width + heightConstraint?.constant = size.height + expandedDetailHeightConstraint?.constant = detailHeight + applyFinalVisibility() + if changed { + updateActionRailVisibility() + needsLayout = true + } + return + } + + if stableHeaderRollout { + widthConstraint?.constant = size.width + fullContentView?.isHidden = false + fullContentView?.alphaValue = 1 + compactContentView?.alphaValue = 1 + contentView.layer?.cornerRadius = targetCornerRadius + contentView.layer?.backgroundColor = cardSurfaceColor( + emphasized: isActiveSelection || isKeyboardFocused || isHovered, + selected: isSelected + ) + applyCardShadow(emphasized: isActiveSelection || isKeyboardFocused || isHovered) + + if isExpanded && !previousExpanded { + heightConstraint?.constant = previousPresentation.size(for: layout).height + expandedDetailHeightConstraint?.constant = 0 + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + #if DEBUG + debugAnimatedPresentationChangeCount += 1 + #endif + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming + context.allowsImplicitAnimation = true + heightConstraint?.animator().constant = size.height + expandedDetailHeightConstraint?.animator().constant = detailHeight + superview?.animator().layoutSubtreeIfNeeded() + animator().layoutSubtreeIfNeeded() + } completionHandler: { + applyFinalVisibility() + } + + if changed { + updateActionRailVisibility() + needsLayout = true + } + return + } + + if !isExpanded && previousExpanded { + heightConstraint?.constant = previousPresentation.size(for: layout).height + expandedDetailHeightConstraint?.constant = expandedDetailHeight + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + #if DEBUG + debugAnimatedPresentationChangeCount += 1 + #endif + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming + context.allowsImplicitAnimation = true + heightConstraint?.animator().constant = size.height + expandedDetailHeightConstraint?.animator().constant = detailHeight + superview?.animator().layoutSubtreeIfNeeded() + animator().layoutSubtreeIfNeeded() + } completionHandler: { + applyFinalVisibility() + } + + if changed { + updateActionRailVisibility() + needsLayout = true + } + return + } + + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + #if DEBUG + debugAnimatedPresentationChangeCount += 1 + #endif + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming + context.allowsImplicitAnimation = true + heightConstraint?.animator().constant = size.height + expandedDetailHeightConstraint?.animator().constant = detailHeight + superview?.animator().layoutSubtreeIfNeeded() + animator().layoutSubtreeIfNeeded() + } completionHandler: { + applyFinalVisibility() + } + + if changed { + updateActionRailVisibility() + needsLayout = true + } + return + } + + superview?.layoutSubtreeIfNeeded() + layoutSubtreeIfNeeded() + #if DEBUG + debugAnimatedPresentationChangeCount += 1 + #endif + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming + widthConstraint?.animator().constant = size.width + heightConstraint?.animator().constant = size.height + expandedDetailHeightConstraint?.animator().constant = detailHeight + fullContentView?.animator().alphaValue = isExpanded ? 1 : 0 + compactContentView?.animator().alphaValue = 1 + contentView.layer?.cornerRadius = targetCornerRadius + superview?.animator().layoutSubtreeIfNeeded() + animator().layoutSubtreeIfNeeded() + } completionHandler: { + applyFinalVisibility() + } + + if changed { + updateActionRailVisibility() + needsLayout = true + } + } + + private func animateLayerChanges(_ changes: () -> Void) { + CATransaction.begin() + if window == nil { + CATransaction.setDisableActions(true) + } else { + CATransaction.setAnimationDuration(0.16) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) + } + changes() + CATransaction.commit() + } + + private func applyCardShadow(emphasized: Bool) { + if presentation == .verticalRow || presentation == .verticalFocus { + layer?.shadowOpacity = 0 + layer?.shadowRadius = 0 + layer?.shadowOffset = .zero + return + } + layer?.shadowOpacity = emphasized ? 0.24 : 0.09 + layer?.shadowRadius = emphasized ? 24 : 12 + layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 12 : 3) + } + + private func cardSurfaceColor(emphasized: Bool, selected: Bool) -> CGColor { + if presentation == .verticalRow || presentation == .verticalFocus { + return NSColor.clear.cgColor + } + if emphasized { + return Palette.selectedSurface + } + return selected ? Palette.cardSurface : Palette.cardSurface + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { isKeyboardFocused = true - onSelect(index) - setSelected(isSelected) + onSelect(index, .activate) + setSelectionState(active: isActiveSelection, selected: isSelected, selectionCount: selectedGroupCount) return true } override func resignFirstResponder() -> Bool { isKeyboardFocused = false - setSelected(isSelected) + setSelectionState(active: isActiveSelection, selected: isSelected, selectionCount: selectedGroupCount) return true } override func keyDown(with event: NSEvent) { + let relevantModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if relevantModifiers == [.command, .shift] { + switch event.keyCode { + case 9, 36, 76: + onPastePlainText(index) + default: + super.keyDown(with: event) + } + return + } + if relevantModifiers == .command { + switch event.keyCode { + case 0: + onSelectAll() + case 5: + onShowInClipboard(index) + case 6: + onUndoDelete() + case 8: + onCopy(index) + case 14 where canEditText: + onEditText(index) + case 15: + onRename(index) + case 16: + onPreview(index) + case 31 where canOpen: + onOpen(index) + default: + super.keyDown(with: event) + } + return + } + if relevantModifiers == .shift { + switch event.keyCode { + case 36, 76: + onPastePlainText(index) + case 115: + onExtendSelectionToFirst() + case 116: + onPageExtendSelection(-1) + case 119: + onExtendSelectionToLast() + case 121: + onPageExtendSelection(1) + case 123: + onExtendSelection(-1) + case 124: + onExtendSelection(1) + default: + super.keyDown(with: event) + } + return + } + switch event.keyCode { case 36, 76: onPaste(index) @@ -2763,6 +6235,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } else { onPaste(index) } + case 51, 117: + onDelete(index) case 115: onSelectFirst() case 116: @@ -2796,7 +6270,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } let tracking = NSTrackingArea( rect: bounds, - options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil ) @@ -2804,15 +6278,30 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { trackingAreaRef = tracking } + fileprivate func setHoverState(_ hovered: Bool, notifySelection: Bool, mouseLocation: NSPoint? = nil) { + let wasHovered = isHovered + if hovered && notifySelection && !wasHovered { + guard onHover(index, mouseLocation) else { return } + } + isHovered = hovered + if wasHovered != hovered { + setSelectionState(active: isActiveSelection, selected: isSelected, selectionCount: selectedGroupCount) + if !hovered { + onHoverExit(index) + } + } + } + override func mouseEntered(with event: NSEvent) { - onSelect(index) - isHovered = true - setSelected(isSelected) + setHoverState(true, notifySelection: true, mouseLocation: event.locationInWindow) + } + + override func mouseMoved(with event: NSEvent) { + setHoverState(true, notifySelection: true, mouseLocation: event.locationInWindow) } override func mouseExited(with event: NSEvent) { - isHovered = false - setSelected(isSelected) + setHoverState(false, notifySelection: false) } override func mouseDown(with event: NSEvent) { @@ -2821,7 +6310,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onPaste(index) } else { mouseDownLocation = convert(event.locationInWindow, from: nil) - onSelect(index) + onSelect(index, selectionMode(from: event.modifierFlags)) } } @@ -2835,7 +6324,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { mouseDownLocation = nil let writers = onPasteboardWriters(index) let dragWriters = writers.isEmpty ? [internalDragPasteboardItem()] : writers - onSelect(index) + onSelect(index, .activate) ClipboardCardDragContext.itemID = itemID let preview = dragPreviewImage() @@ -2880,13 +6369,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } override func menu(for event: NSEvent) -> NSMenu? { - onSelect(index) + onSelect(index, isSelected ? .activate : .replace) 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 = "" @@ -2894,6 +6384,33 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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 } @@ -2918,14 +6435,31 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } var debugVisibleActionLabels: [String] { - actionRail.isHidden ? [] : actionRailButtons.map { $0.toolTip ?? "" } + actionRailIsPresented ? actionRailButtons.map { $0.toolTip ?? "" } : [] } var debugVisibleActionRailWidth: CGFloat { - guard !actionRail.isHidden else { return 0 } - return actionRail.constraints.first { constraint in - constraint.firstAttribute == .width && constraint.secondItem == nil - }?.constant ?? actionRail.fittingSize.width + actionRailIsPresented ? actionRailContentWidth : 0 + } + + var debugActionRailAlpha: CGFloat { + actionRail.alphaValue + } + + var debugConstructedActionButtonCount: Int { + actionRailButtons.count + } + + 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 { @@ -2941,6 +6475,79 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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 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 + } + var debugStackCornerLabel: String { stackCornerButton.toolTip ?? "" } @@ -2957,6 +6564,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { stackCornerButton.performClick(nil) } + 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 } @@ -2965,6 +6585,37 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { isKeyboardFocused } + var debugIsSelected: Bool { + isSelected + } + + var debugIsActiveSelection: Bool { + isActiveSelection + } + + var debugIsHovered: Bool { + isHovered + } + + var debugPresentation: String { + switch presentation { + case .expanded: + return "expanded" + case .horizontalIcon: + return "horizontal-icon" + case .horizontalFocus: + return "horizontal-focus" + case .verticalRow: + return "vertical-row" + case .verticalFocus: + return "vertical-focus" + } + } + + func debugSetHovered(_ hovered: Bool) { + setHoverState(hovered, notifySelection: hovered) + } + var debugBorderWidth: CGFloat { contentView.layer?.borderWidth ?? 0 } @@ -2993,12 +6644,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private func contextMenu() -> NSMenu { let menu = NSMenu() menu.autoenablesItems = false - addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu) - addMenuItem("Copy", action: #selector(copyFromMenu), to: menu) + addMenuItem(selectedGroupCount > 1 ? "Paste Selection" : "Paste", action: #selector(pasteFromMenu), to: menu) + addMenuItem(selectedGroupCount > 1 ? "Copy Selection" : "Copy", action: #selector(copyFromMenu), to: menu) if canPlainText { addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu) addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), 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) + } if canShowInClipboard { addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu) } @@ -3015,6 +6671,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { if canEditText { addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu) } + if canRotateImage { + addMenuItem("Rotate Image", action: #selector(rotateImageFromMenu), to: menu) + } + if canExtractImageText { + addMenuItem("Extract Text", action: #selector(extractImageTextFromMenu), to: menu) + } if canPreview { addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu) } @@ -3161,6 +6823,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { itemKind == .text || itemKind == .code } + private var canRotateImage: Bool { + itemKind == .image + } + + private var canExtractImageText: Bool { + itemKind == .image + } + private var canPlainText: Bool { switch itemKind { case .url, .image, .richText, .file, .pdf, .audio, .color, .video: @@ -3186,20 +6856,30 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { actionRail.edgeInsets = NSEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) actionRail.wantsLayer = true actionRail.layer?.cornerRadius = layout.actionRailHeight / 2 - actionRail.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.44).cgColor + actionRail.layer?.backgroundColor = Palette.actionRailBackground actionRail.layer?.borderWidth = 0.5 - actionRail.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor + actionRail.layer?.borderColor = Palette.actionRailBorder actionRail.layer?.shadowColor = NSColor.black.cgColor - actionRail.layer?.shadowOpacity = 0.18 - actionRail.layer?.shadowRadius = 10 - actionRail.layer?.shadowOffset = NSSize(width: 0, height: 4) + actionRail.layer?.shadowOpacity = 0.12 + actionRail.layer?.shadowRadius = 8 + actionRail.layer?.shadowOffset = NSSize(width: 0, height: 3) actionRail.translatesAutoresizingMaskIntoConstraints = false + actionRail.alphaValue = 0 + actionRail.isHidden = true actionRail.heightAnchor.constraint(equalToConstant: layout.actionRailHeight).isActive = true actionRail.setContentHuggingPriority(.required, for: .horizontal) actionRail.setContentCompressionResistancePriority(.required, for: .horizontal) + actionRailWidthConstraint = actionRail.widthAnchor.constraint(equalToConstant: 0) + actionRailWidthConstraint?.isActive = true + updateActionRailVisibility() + } + + private func ensureActionRailButtons() { + guard actionRailButtons.isEmpty else { return } + let specs = fittedActionRailButtonSpecs(from: preferredActionRailButtonSpecs()) - actionRailButtons = specs.map { spec in + let buttons = specs.map { spec in cardActionButton( spec.systemName, toolTip: spec.toolTip, @@ -3208,12 +6888,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { ) } - for button in actionRailButtons { + for button in buttons { actionRail.addArrangedSubview(button) } + actionRailButtons = buttons let contentWidth = actionRailWidth(for: specs) - actionRail.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true - updateActionRailVisibility() + actionRailContentWidth = contentWidth + if actionRailIsPresented { + actionRailWidthConstraint?.constant = contentWidth + } } private func preferredActionRailButtonSpecs() -> [ActionRailButtonSpec] { @@ -3234,6 +6917,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { if canEditText { specs.append(ActionRailButtonSpec("pencil", toolTip: "Edit", action: #selector(editTextFromMenu), overflowPriority: 50)) } + if canRotateImage { + specs.append(ActionRailButtonSpec("rotate.right", toolTip: "Rotate Image", action: #selector(rotateImageFromMenu), overflowPriority: 50)) + } + if canExtractImageText { + specs.append(ActionRailButtonSpec("text.viewfinder", toolTip: "Extract Text", action: #selector(extractImageTextFromMenu), overflowPriority: 55)) + } if canPreview { specs.append(ActionRailButtonSpec("eye", toolTip: "Preview", action: #selector(previewFromMenu), overflowPriority: 60)) } @@ -3287,14 +6976,27 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private var maximumVisibleActionRailWidth: CGFloat { - layout.width - - Metrics.actionRailLeadingMargin - - Metrics.actionRailBadgeGap - - headerBadgeSize + layout.width - (Metrics.actionRailLeadingMargin * 2) + } + + private var summaryHeaderHeight: CGFloat { + ClipboardCardPresentation.verticalRow.size(for: layout).height + } + + private var expandedDetailHeight: CGFloat { + max(0, layout.height - summaryHeaderHeight) + } + + private var expandedBodyHeight: CGFloat { + max(1, expandedDetailHeight - layout.footerHeight) } private var headerBadgeSize: CGFloat { - layout.headerHeight + summaryHeaderHeight + } + + private var verticalHeaderCornerRadius: CGFloat { + min(layout.width, summaryHeaderHeight) / 3 } private var headerBadgeCornerRadius: CGFloat { @@ -3302,7 +7004,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private var headerBadgeIconInset: CGFloat { - layout.isCompact ? 9 : 10 + layout.isCompact ? 7 : 8 + } + + private var headerBadgeAppIconBleed: CGFloat { + layout.isCompact ? 6 : 7 } private var stackCornerButtonSize: CGFloat { @@ -3313,6 +7019,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { max(8, (layout.headerHeight - layout.actionRailHeight) / 2) } + private func cardCornerRadius(for presentation: ClipboardCardPresentation, size: NSSize) -> CGFloat { + switch presentation { + case .verticalRow, .verticalFocus: + return verticalHeaderCornerRadius + case .horizontalIcon: + return min(size.width, size.height) / 2 + case .horizontalFocus: + return min(size.width, size.height) / 3 + case .expanded: + return 8 + } + } + private func cardActionButton( _ systemName: String, toolTip: String, @@ -3330,16 +7049,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize button.layer?.cornerRadius = size / 2 if isPrimary { - button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor + button.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.92).cgColor button.contentTintColor = .white } else if toolTip == "Collect" { - button.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.88).cgColor + button.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.86).cgColor button.contentTintColor = .white + } else if toolTip == "Delete" { + button.layer?.backgroundColor = NSColor.clear.cgColor + button.contentTintColor = NSColor.systemRed.withAlphaComponent(0.78) } else { - button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor - button.contentTintColor = toolTip == "Delete" - ? NSColor.white.withAlphaComponent(0.48) - : NSColor.white.withAlphaComponent(0.78) + button.layer?.backgroundColor = Palette.secondaryActionBackground + button.contentTintColor = NSColor.labelColor.withAlphaComponent(0.74) } button.toolTip = toolTip button.setAccessibilityLabel(toolTip) @@ -3377,15 +7097,44 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func updateActionRailVisibility() { - actionRail.isHidden = !isSelected + let shouldShowActionRail = presentation.showsFloatingActions && (isHovered || (isActiveSelection && isKeyboardFocused)) + if shouldShowActionRail { + ensureActionRailButtons() + } + let targetWidth = shouldShowActionRail ? actionRailContentWidth : 0 + let visibilityChanged = actionRailIsPresented != shouldShowActionRail + actionRailIsPresented = shouldShowActionRail headerBadgeView?.isHidden = false - headerPinView?.isHidden = isSelected + headerPinView?.isHidden = isActiveSelection footerDetailLabel.isHidden = false - stackCornerButton.isHidden = !(itemIsStacked || isSelected || isHovered || isKeyboardFocused) + stackCornerButton.isHidden = !presentation.showsFloatingActions || !(itemIsStacked || isKeyboardFocused) stackCornerButton.alphaValue = itemIsStacked ? 1.0 : 0.94 for button in actionRailButtons { button.alphaValue = 1.0 } + + guard window != nil, visibilityChanged else { + actionRailWidthConstraint?.constant = targetWidth + actionRail.alphaValue = shouldShowActionRail ? 1 : 0 + actionRail.isHidden = !shouldShowActionRail + return + } + + if shouldShowActionRail { + actionRailWidthConstraint?.constant = targetWidth + actionRail.isHidden = false + actionRail.alphaValue = max(actionRail.alphaValue, 0.01) + } + NSAnimationContext.runAnimationGroup { context in + context.duration = ClipboardPanelView.Motion.actionRailDuration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + actionRail.animator().alphaValue = shouldShowActionRail ? 1 : 0 + } completionHandler: { [weak self] in + guard let self else { return } + self.actionRailWidthConstraint?.constant = targetWidth + self.actionRail.alphaValue = shouldShowActionRail ? 1 : 0 + self.actionRail.isHidden = !shouldShowActionRail + } } @objc private func pasteFromMenu() { @@ -3404,6 +7153,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onCopyPlainText(index) } + @objc private func pasteSelectionTextFromMenu() { + onPasteSelectionText(index) + } + + @objc private func copySelectionTextFromMenu() { + onCopySelectionText(index) + } + + @objc private func addSelectionToStackFromMenu() { + onAddSelectionToStack(index) + } + @objc private func showMoreActionsFromActionRail(_ sender: NSButton) { contextMenu().popUp( positioning: nil, @@ -3421,7 +7182,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } @objc private func toggleStackFromCornerButton() { - onSelect(index) + onSelect(index, .activate) onToggleStack(index) } @@ -3457,6 +7218,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onEditText(index) } + @objc private func rotateImageFromMenu() { + onRotateImage(index) + } + + @objc private func extractImageTextFromMenu() { + onExtractImageText(index) + } + @objc private func previewFromMenu() { onPreview(index) } @@ -3540,9 +7309,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { setAccessibilityRole(.button) setAccessibilityLabel(accessibilityTitle(for: item)) setAccessibilityHelp(accessibilityHelpText()) - widthAnchor.constraint(equalToConstant: layout.width).isActive = true - heightAnchor.constraint(equalToConstant: layout.height).isActive = true - focusRingType = .default + widthConstraint = widthAnchor.constraint(equalToConstant: layout.width) + heightConstraint = heightAnchor.constraint(equalToConstant: layout.height) + widthConstraint?.isActive = true + heightConstraint?.isActive = true + focusRingType = .none contentView.wantsLayer = true contentView.layer?.cornerRadius = 8 @@ -3553,88 +7324,115 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { contentView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentView) - let header = headerView(for: item) + let header = compactSummaryView(for: item) let body = bodyView(for: item, thumbnail: thumbnail) let footer = footerView(for: item) + let fullContainer = NSView() + fullContainer.wantsLayer = true + fullContainer.layer?.masksToBounds = true + fullContainer.layer?.backgroundColor = NSColor.clear.cgColor + fullContainer.translatesAutoresizingMaskIntoConstraints = false + fullContentView = fullContainer - let stack = NSStackView(views: [header, body, footer]) + let stack = NSStackView(views: [body, footer]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 0 stack.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(stack) + fullContainer.addSubview(stack) + contentView.addSubview(header) + contentView.addSubview(fullContainer) + + header.translatesAutoresizingMaskIntoConstraints = false + compactContentView = header + let detailHeightConstraint = fullContainer.heightAnchor.constraint(equalToConstant: expandedDetailHeight) + expandedDetailHeightConstraint = detailHeightConstraint NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: leadingAnchor), contentView.trailingAnchor.constraint(equalTo: trailingAnchor), contentView.topAnchor.constraint(equalTo: topAnchor), - contentView.bottomAnchor.constraint(equalTo: bottomAnchor), - stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - stack.topAnchor.constraint(equalTo: contentView.topAnchor), - stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - header.widthAnchor.constraint(equalTo: stack.widthAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + fullContentConstraints = [ + fullContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + fullContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + fullContainer.topAnchor.constraint(equalTo: header.bottomAnchor), + detailHeightConstraint, + stack.leadingAnchor.constraint(equalTo: fullContainer.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: fullContainer.trailingAnchor), + stack.topAnchor.constraint(equalTo: fullContainer.topAnchor), body.widthAnchor.constraint(equalTo: stack.widthAnchor), footer.widthAnchor.constraint(equalTo: stack.widthAnchor) - ]) + ] + compactContentConstraints = [ + header.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + header.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + header.topAnchor.constraint(equalTo: contentView.topAnchor), + header.heightAnchor.constraint(equalToConstant: summaryHeaderHeight) + ] + NSLayoutConstraint.activate(fullContentConstraints) + NSLayoutConstraint.activate(compactContentConstraints) + configureStackCornerButton() contentView.addSubview(stackCornerButton) - contentView.addSubview(actionRail) - let actionRailTrailingConstraint: NSLayoutConstraint - if let headerBadgeView { - actionRailTrailingConstraint = actionRail.trailingAnchor.constraint( - equalTo: headerBadgeView.leadingAnchor, - constant: -Metrics.actionRailBadgeGap - ) - } else { - actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12) - } + addSubview(actionRail) NSLayoutConstraint.activate([ stackCornerButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), - stackCornerButton.centerYAnchor.constraint(equalTo: footer.topAnchor), + stackCornerButton.centerYAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -layout.footerHeight), stackCornerButton.widthAnchor.constraint(equalToConstant: stackCornerButtonSize), stackCornerButton.heightAnchor.constraint(equalToConstant: stackCornerButtonSize), - actionRailTrailingConstraint, - actionRail.topAnchor.constraint(equalTo: contentView.topAnchor, constant: actionRailHeaderTopInset) + actionRail.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + actionRail.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 5) ]) + setPresentation(.expanded) setSelected(false) } - private func headerView(for item: ClipboardItem) -> NSView { + private func compactSummaryView(for item: ClipboardItem) -> NSView { let header = NSView() header.wantsLayer = true - header.layer?.backgroundColor = headerColor(for: item).cgColor - header.heightAnchor.constraint(equalToConstant: layout.headerHeight).isActive = true + header.layer?.backgroundColor = headerColor(for: item).withAlphaComponent(0.96).cgColor + header.layer?.cornerRadius = verticalHeaderCornerRadius + header.layer?.maskedCorners = CornerMasks.topOnly + header.layer?.masksToBounds = true + header.layer?.zPosition = 2 - let kind = NSTextField(labelWithString: headerTitle(for: item)) - kind.font = .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .bold) - kind.textColor = .white - kind.lineBreakMode = .byTruncatingTail - kind.maximumNumberOfLines = 1 - kind.toolTip = kind.stringValue + 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 source = NSTextField(labelWithString: headerSubtitle(for: item)) - source.font = .systemFont(ofSize: layout.isCompact ? 10 : 11, weight: .regular) - source.textColor = NSColor.white.withAlphaComponent(0.72) - source.lineBreakMode = .byTruncatingTail - source.maximumNumberOfLines = 1 - source.toolTip = source.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 titleAndSource = NSStackView(views: [kind, source]) - titleAndSource.orientation = .vertical - titleAndSource.alignment = .leading - titleAndSource.spacing = 2 - titleAndSource.translatesAutoresizingMaskIntoConstraints = false + let titleAndSubtitle = NSStackView(views: [title, subtitle]) + titleAndSubtitle.orientation = .vertical + titleAndSubtitle.alignment = .leading + titleAndSubtitle.spacing = 2 + titleAndSubtitle.translatesAutoresizingMaskIntoConstraints = false + compactTextStack = titleAndSubtitle + + let metricLabel = compactMetricLabel(for: item) + compactMetricLabel = metricLabel var labelViews: [NSView] = [] if let quickPasteBadge = quickPasteBadge() { labelViews.append(quickPasteBadge) } - labelViews.append(titleAndSource) + labelViews.append(titleAndSubtitle) + labelViews.append(metricLabel) let labelStack = NSStackView(views: labelViews) labelStack.orientation = .horizontal labelStack.alignment = .centerY labelStack.distribution = .fill + labelStack.detachesHiddenViews = true labelStack.spacing = labelViews.count > 1 ? 9 : 1 labelStack.translatesAutoresizingMaskIntoConstraints = false @@ -3647,10 +7445,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { header.addSubview(labelStack) header.addSubview(badge) header.addSubview(separator) - kind.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - kind.setContentHuggingPriority(.defaultHigh, for: .horizontal) - source.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - source.setContentHuggingPriority(.defaultLow, for: .horizontal) + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + title.setContentHuggingPriority(.defaultLow, for: .horizontal) + subtitle.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + subtitle.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleAndSubtitle.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleAndSubtitle.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + metricLabel.setContentHuggingPriority(.required, for: .horizontal) + metricLabel.setContentCompressionResistancePriority(.required, for: .horizontal) var constraints: [NSLayoutConstraint] = [ labelStack.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset), @@ -3683,6 +7485,18 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return header } + 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 = NSColor.white.withAlphaComponent(0.82) + label.alignment = .right + label.lineBreakMode = .byClipping + label.maximumNumberOfLines = 1 + label.toolTip = detailMetricText(for: item) + label.isHidden = true + return label + } + private func headerTitle(for item: ClipboardItem) -> String { activeCollectionName ?? kindLabel(for: item.kind) } @@ -3722,7 +7536,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let body = NSView() body.wantsLayer = true body.layer?.backgroundColor = Palette.bodyBackground - body.heightAnchor.constraint(equalToConstant: layout.bodyHeight).isActive = true + body.heightAnchor.constraint(equalToConstant: expandedBodyHeight).isActive = true let content = previewView(for: item, thumbnail: thumbnail) body.addSubview(content) @@ -3736,6 +7550,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func previewView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView { + #if !DEBUG + return lightweightPreviewView(for: item, thumbnail: thumbnail) + #else if item.kind == .image, let thumbnail { return mediaPreviewView(for: item, thumbnail: thumbnail) } @@ -3753,12 +7570,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { if FilePayload.paths(from: item.payload).count > 1 { return multiFilePreviewView(for: item) } - return filePreviewView(for: item, thumbnail: thumbnail) + return filePreviewView(for: item) case .pdf: if let thumbnail { return mediaPreviewView(for: item, thumbnail: thumbnail) } - return filePreviewView(for: item, thumbnail: thumbnail) + return filePreviewView(for: item) case .audio: return audioPreviewView(for: item) case .video: @@ -3773,10 +7590,81 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .text, .richText, .image, .unknown: return textPreviewView(for: item) } + #endif + } + + private func lightweightPreviewView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView { + let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.055).cgColor + container.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(wrappingLabelWithString: titleText(for: item)) + title.font = .systemFont(ofSize: layout.isCompact ? 13 : 14, weight: .semibold) + title.textColor = .labelColor + title.maximumNumberOfLines = thumbnail == nil ? 3 : 1 + title.lineBreakMode = .byTruncatingTail + title.toolTip = title.stringValue + + let detail = NSTextField(wrappingLabelWithString: previewText(for: item)) + detail.font = .systemFont(ofSize: layout.isCompact ? 11 : 12) + detail.textColor = .secondaryLabelColor + detail.maximumNumberOfLines = thumbnail == nil ? 2 : 1 + detail.lineBreakMode = .byTruncatingTail + detail.toolTip = detail.stringValue + + let textStack = NSStackView(views: [title, detail]) + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.spacing = 4 + textStack.translatesAutoresizingMaskIntoConstraints = false + + if let thumbnail { + let imageView = NSImageView(image: thumbnail) + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.wantsLayer = true + imageView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.04).cgColor + imageView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(imageView) + container.addSubview(textStack) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + imageView.topAnchor.constraint(equalTo: container.topAnchor), + imageView.heightAnchor.constraint(equalToConstant: min(layout.bodyHeight * 0.58, layout.isCompact ? 94 : 112)), + textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + textStack.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10), + title.widthAnchor.constraint(equalTo: textStack.widthAnchor), + detail.widthAnchor.constraint(equalTo: textStack.widthAnchor) + ]) + } else { + let icon = headerIcon(headerBadgeSymbol(for: item.kind), color: accentColor(for: item.kind)) + icon.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(icon) + container.addSubview(textStack) + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + icon.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 16 : 18), + icon.widthAnchor.constraint(equalToConstant: layout.isCompact ? 30 : 34), + icon.heightAnchor.constraint(equalToConstant: layout.isCompact ? 30 : 34), + textStack.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12), + textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + textStack.topAnchor.constraint(equalTo: icon.topAnchor, constant: -1), + title.widthAnchor.constraint(equalTo: textStack.widthAnchor), + detail.widthAnchor.constraint(equalTo: textStack.widthAnchor) + ]) + } + return container } private func textPreviewView(for item: ClipboardItem) -> NSView { let container = NSView() + let accent = accentColor(for: item.kind) + let paperColor = NSColor.windowBackgroundColor.withAlphaComponent(0.76) + container.wantsLayer = true + container.layer?.backgroundColor = accent.withAlphaComponent(0.035).cgColor container.translatesAutoresizingMaskIntoConstraints = false let titleString = titleText(for: item) @@ -3784,13 +7672,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { #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 ? 15 : 14, weight: .regular) - : .systemFont(ofSize: 13, weight: .semibold) + ? .systemFont(ofSize: item.kind == .richText ? 16 : 15, weight: .semibold) + : .systemFont(ofSize: 13, weight: .bold) title.textColor = .labelColor - title.maximumNumberOfLines = bodyString == nil ? 5 : 1 + title.maximumNumberOfLines = bodyString == nil ? 4 : 1 title.lineBreakMode = .byTruncatingTail title.toolTip = title.stringValue @@ -3799,7 +7690,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let detail = NSTextField(wrappingLabelWithString: bodyString) detail.font = .systemFont(ofSize: item.kind == .richText ? 15 : 14) detail.textColor = .secondaryLabelColor - detail.maximumNumberOfLines = 5 + detail.maximumNumberOfLines = layout.isCompact ? 4 : 5 detail.lineBreakMode = .byTruncatingTail detail.toolTip = detail.stringValue textViews.append(detail) @@ -3810,64 +7701,150 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { stack.alignment = .leading stack.spacing = bodyString == nil ? 0 : 10 stack.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(stack) + + let paper = NSView() + paper.wantsLayer = true + paper.layer?.cornerRadius = layout.isCompact ? 8 : 10 + paper.layer?.backgroundColor = paperColor.cgColor + paper.layer?.borderWidth = 0.8 + paper.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.10).cgColor + paper.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(paper) + paper.addSubview(stack) + let fade = bodyString == nil ? nil : textPreviewBottomFade(color: paperColor) + if let fade { + paper.addSubview(fade) + } for view in textViews { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), - stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), - stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 16), - stack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -14) - ]) + var constraints = [ + paper.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + paper.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + paper.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 12 : 14), + paper.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -12 : -14), + stack.leadingAnchor.constraint(equalTo: paper.leadingAnchor, constant: layout.isCompact ? 12 : 14), + stack.trailingAnchor.constraint(equalTo: paper.trailingAnchor, constant: layout.isCompact ? -12 : -14), + stack.topAnchor.constraint(equalTo: paper.topAnchor, constant: layout.isCompact ? 12 : 14), + stack.bottomAnchor.constraint(lessThanOrEqualTo: paper.bottomAnchor, constant: layout.isCompact ? -12 : -14) + ] + if let fade { + constraints += [ + fade.leadingAnchor.constraint(equalTo: paper.leadingAnchor), + fade.trailingAnchor.constraint(equalTo: paper.trailingAnchor), + fade.bottomAnchor.constraint(equalTo: paper.bottomAnchor), + fade.heightAnchor.constraint(equalToConstant: layout.isCompact ? 24 : 30) + ] + } + NSLayoutConstraint.activate(constraints) return container } + private func textPreviewBottomFade(color: NSColor) -> NSView { + let fade = TextPreviewBottomFadeView(color: color) + fade.translatesAutoresizingMaskIntoConstraints = false + return fade + } + private func linkPreviewView(for item: ClipboardItem) -> NSView { let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.22).cgColor container.translatesAutoresizingMaskIntoConstraints = false let hostText = webHostText(from: item.payload) ?? "Link" 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 - hero.layer?.backgroundColor = siteColor.withAlphaComponent(0.16).cgColor + hero.layer?.backgroundColor = siteColor.withAlphaComponent(0.09).cgColor hero.translatesAutoresizingMaskIntoConstraints = false - hero.heightAnchor.constraint(equalToConstant: 82).isActive = true + hero.heightAnchor.constraint(equalToConstant: heroHeight).isActive = true - let tile = NSView() - tile.wantsLayer = true - tile.layer?.cornerRadius = 18 - tile.layer?.backgroundColor = siteColor.cgColor - tile.layer?.shadowColor = NSColor.black.cgColor - tile.layer?.shadowOpacity = 0.14 - tile.layer?.shadowRadius = 9 - tile.layer?.shadowOffset = NSSize(width: 0, height: 4) - tile.translatesAutoresizingMaskIntoConstraints = false + let browser = NSView() + browser.wantsLayer = true + browser.layer?.cornerRadius = 14 + browser.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.74).cgColor + browser.layer?.borderWidth = 0.8 + browser.layer?.borderColor = NSColor.white.withAlphaComponent(0.78).cgColor + browser.layer?.shadowColor = NSColor.black.cgColor + browser.layer?.shadowOpacity = 0.10 + browser.layer?.shadowRadius = 8 + browser.layer?.shadowOffset = NSSize(width: 0, height: 3) + browser.translatesAutoresizingMaskIntoConstraints = false - let monogram = NSTextField(labelWithString: linkMonogram(from: hostText)) - monogram.font = .systemFont(ofSize: 20, weight: .heavy) + let dotStack = NSStackView(views: [ + browserDot(NSColor.systemRed.withAlphaComponent(0.74)), + browserDot(NSColor.systemYellow.withAlphaComponent(0.74)), + browserDot(NSColor.systemGreen.withAlphaComponent(0.74)) + ]) + dotStack.orientation = .horizontal + dotStack.alignment = .centerY + dotStack.spacing = 4 + dotStack.translatesAutoresizingMaskIntoConstraints = false + + let addressPill = NSView() + addressPill.wantsLayer = true + addressPill.layer?.cornerRadius = 6 + 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 + host.translatesAutoresizingMaskIntoConstraints = false + addressPill.addSubview(host) + + let favicon = NSView() + favicon.wantsLayer = true + favicon.layer?.cornerRadius = 10 + favicon.layer?.backgroundColor = siteColor.cgColor + favicon.layer?.shadowColor = NSColor.black.cgColor + favicon.layer?.shadowOpacity = 0.12 + favicon.layer?.shadowRadius = 5 + 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 monogram.translatesAutoresizingMaskIntoConstraints = false - tile.addSubview(monogram) + favicon.addSubview(monogram) - let host = NSTextField(labelWithString: hostText) - host.font = .systemFont(ofSize: 12, weight: .semibold) - host.textColor = siteColor - host.alignment = .center - host.lineBreakMode = .byTruncatingTail - host.maximumNumberOfLines = 1 - host.toolTip = host.stringValue + let lineStack = NSStackView(views: [ + previewLine(color: siteColor.withAlphaComponent(0.44)), + previewLine(color: NSColor.labelColor.withAlphaComponent(0.14)), + previewLine(color: NSColor.labelColor.withAlphaComponent(0.09)) + ]) + lineStack.orientation = .vertical + lineStack.alignment = .leading + lineStack.spacing = 5 + lineStack.translatesAutoresizingMaskIntoConstraints = false + for (index, view) in lineStack.arrangedSubviews.enumerated() { + view.heightAnchor.constraint(equalToConstant: index == 0 ? 7 : 5).isActive = true + } - let heroStack = NSStackView(views: [tile, host]) - heroStack.orientation = .vertical - heroStack.alignment = .centerX - heroStack.spacing = 6 - heroStack.translatesAutoresizingMaskIntoConstraints = false - hero.addSubview(heroStack) + browser.addSubview(dotStack) + browser.addSubview(addressPill) + browser.addSubview(favicon) + browser.addSubview(lineStack) + hero.addSubview(browser) let title = NSTextField(wrappingLabelWithString: titleText(for: item)) title.font = .systemFont(ofSize: 14, weight: .semibold) @@ -3895,17 +7872,35 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { hero.leadingAnchor.constraint(equalTo: container.leadingAnchor), hero.trailingAnchor.constraint(equalTo: container.trailingAnchor), hero.topAnchor.constraint(equalTo: container.topAnchor), - heroStack.centerXAnchor.constraint(equalTo: hero.centerXAnchor), - heroStack.centerYAnchor.constraint(equalTo: hero.centerYAnchor), - tile.widthAnchor.constraint(equalToConstant: 44), - tile.heightAnchor.constraint(equalToConstant: 44), - monogram.leadingAnchor.constraint(equalTo: tile.leadingAnchor, constant: 6), - monogram.trailingAnchor.constraint(equalTo: tile.trailingAnchor, constant: -6), - monogram.centerYAnchor.constraint(equalTo: tile.centerYAnchor), - host.widthAnchor.constraint(lessThanOrEqualTo: hero.widthAnchor, constant: -48), + browser.leadingAnchor.constraint(equalTo: hero.leadingAnchor, constant: layout.inset), + browser.trailingAnchor.constraint(equalTo: hero.trailingAnchor, constant: -layout.inset), + browser.topAnchor.constraint(equalTo: hero.topAnchor, constant: linkPreviewBrowserInset), + browser.bottomAnchor.constraint(equalTo: hero.bottomAnchor, constant: -linkPreviewBrowserInset), + dotStack.leadingAnchor.constraint(equalTo: browser.leadingAnchor, constant: 12), + dotStack.centerYAnchor.constraint(equalTo: addressPill.centerYAnchor), + addressPill.leadingAnchor.constraint(equalTo: dotStack.trailingAnchor, constant: 10), + addressPill.trailingAnchor.constraint(equalTo: browser.trailingAnchor, constant: -12), + addressPill.topAnchor.constraint(equalTo: browser.topAnchor, constant: 9), + addressPill.heightAnchor.constraint(equalToConstant: 14), + host.leadingAnchor.constraint(equalTo: addressPill.leadingAnchor, constant: 7), + host.trailingAnchor.constraint(equalTo: addressPill.trailingAnchor, constant: -7), + host.centerYAnchor.constraint(equalTo: addressPill.centerYAnchor), + favicon.leadingAnchor.constraint(equalTo: browser.leadingAnchor, constant: 12), + favicon.topAnchor.constraint(equalTo: addressPill.bottomAnchor, constant: linkPreviewContentGap), + favicon.widthAnchor.constraint(equalToConstant: linkPreviewFaviconSize), + favicon.heightAnchor.constraint(equalToConstant: linkPreviewFaviconSize), + monogram.leadingAnchor.constraint(equalTo: favicon.leadingAnchor, constant: 4), + monogram.trailingAnchor.constraint(equalTo: favicon.trailingAnchor, constant: -4), + monogram.centerYAnchor.constraint(equalTo: favicon.centerYAnchor), + lineStack.leadingAnchor.constraint(equalTo: favicon.trailingAnchor, constant: 10), + lineStack.trailingAnchor.constraint(equalTo: browser.trailingAnchor, constant: -14), + lineStack.centerYAnchor.constraint(equalTo: favicon.centerYAnchor), + lineStack.arrangedSubviews[0].widthAnchor.constraint(equalTo: lineStack.widthAnchor), + lineStack.arrangedSubviews[1].widthAnchor.constraint(equalTo: lineStack.widthAnchor, multiplier: 0.78), + lineStack.arrangedSubviews[2].widthAnchor.constraint(equalTo: lineStack.widthAnchor, multiplier: 0.54), textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), - textStack.topAnchor.constraint(equalTo: hero.bottomAnchor, constant: 11), + textStack.topAnchor.constraint(equalTo: hero.bottomAnchor, constant: 10), textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10), title.widthAnchor.constraint(equalTo: textStack.widthAnchor), address.widthAnchor.constraint(equalTo: textStack.widthAnchor) @@ -3913,16 +7908,65 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } + private var linkPreviewHeroHeight: CGFloat { + if layout == .expanded { + return 128 + } + return layout.isCompact ? 78 : 88 + } + + private var linkPreviewBrowserInset: CGFloat { + layout.isCompact ? 9 : 10 + } + + private var linkPreviewContentGap: CGFloat { + layout.isCompact ? 7 : 9 + } + + private var linkPreviewFaviconSize: CGFloat { + if layout == .expanded { + return 38 + } + return layout.isCompact ? 28 : 32 + } + + private func browserDot(_ color: NSColor) -> NSView { + let dot = NSView() + dot.wantsLayer = true + dot.layer?.cornerRadius = 3 + dot.layer?.backgroundColor = color.cgColor + dot.translatesAutoresizingMaskIntoConstraints = false + dot.widthAnchor.constraint(equalToConstant: 6).isActive = true + dot.heightAnchor.constraint(equalToConstant: 6).isActive = true + return dot + } + + private func previewLine(color: NSColor) -> NSView { + let line = NSView() + line.wantsLayer = true + line.layer?.cornerRadius = 2.5 + line.layer?.backgroundColor = color.cgColor + line.translatesAutoresizingMaskIntoConstraints = false + return line + } + private func linkMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView { let container = NSView() + container.wantsLayer = true + 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: 90).isActive = true - - let hostPill = capsuleLabel(webHostText(from: item.payload) ?? "Link", color: NSColor.black.withAlphaComponent(0.56)) - hostPill.translatesAutoresizingMaskIntoConstraints = false + imageView.heightAnchor.constraint(equalToConstant: imageHeight).isActive = true let title = NSTextField(wrappingLabelWithString: titleText(for: item)) title.font = .systemFont(ofSize: 14, weight: .semibold) @@ -3945,14 +7989,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { textStack.translatesAutoresizingMaskIntoConstraints = false container.addSubview(imageView) - container.addSubview(hostPill) container.addSubview(textStack) NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), imageView.topAnchor.constraint(equalTo: container.topAnchor), - hostPill.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: 12), - hostPill.bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: -10), textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), textStack.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10), @@ -3963,81 +8004,157 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } - private func filePreviewView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView { + private var linkMediaPreviewImageHeight: CGFloat { + if layout == .expanded { + return 146 + } + return layout.isCompact ? 80 : 96 + } + + private func filePreviewView(for item: ClipboardItem) -> NSView { let container = NSView() + 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 iconBox = NSView() - iconBox.wantsLayer = true - iconBox.layer?.cornerRadius = 12 - iconBox.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.14).cgColor - iconBox.translatesAutoresizingMaskIntoConstraints = false - - let extensionPill = capsuleLabel(detailMetricText(for: item), color: accentColor(for: item.kind)) - extensionPill.translatesAutoresizingMaskIntoConstraints = false - let preview: NSView - if let thumbnail { - let imageView = NSImageView(image: thumbnail) - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.wantsLayer = true - imageView.layer?.cornerRadius = 8 - imageView.layer?.masksToBounds = true - imageView.translatesAutoresizingMaskIntoConstraints = false - preview = imageView - } else { - let iconName = item.kind == .pdf ? "doc.richtext.fill" : "doc.fill" - let icon = headerIcon(iconName, color: accentColor(for: item.kind)) - icon.translatesAutoresizingMaskIntoConstraints = false - preview = icon - } - iconBox.addSubview(preview) - iconBox.addSubview(extensionPill) + let cover = documentCoverPreviewView(for: item, extensionText: extensionText, accent: accent) let title = NSTextField(wrappingLabelWithString: titleText(for: item)) - title.font = .systemFont(ofSize: 14, weight: .semibold) + title.font = .systemFont(ofSize: layout.isCompact ? 13 : 14, weight: .semibold) title.textColor = .labelColor - title.maximumNumberOfLines = 2 + title.maximumNumberOfLines = 1 title.lineBreakMode = .byTruncatingTail title.toolTip = title.stringValue - let location = NSTextField(wrappingLabelWithString: previewText(for: item)) - location.font = .systemFont(ofSize: 12) + let location = NSTextField(labelWithString: previewText(for: item)) + location.font = .systemFont(ofSize: layout.isCompact ? 11 : 12) location.textColor = .secondaryLabelColor - location.maximumNumberOfLines = 2 + location.maximumNumberOfLines = 1 location.lineBreakMode = .byTruncatingMiddle location.toolTip = location.stringValue let textStack = NSStackView(views: [title, location]) textStack.orientation = .vertical textStack.alignment = .leading - textStack.spacing = 5 + textStack.spacing = 3 textStack.translatesAutoresizingMaskIntoConstraints = false - let row = NSStackView(views: [iconBox, textStack]) - row.orientation = .horizontal - row.alignment = .centerY - row.spacing = 14 - row.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(row) + container.addSubview(cover) + container.addSubview(textStack) NSLayoutConstraint.activate([ - row.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), - row.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), - row.centerYAnchor.constraint(equalTo: container.centerYAnchor), - iconBox.widthAnchor.constraint(equalToConstant: thumbnail == nil ? 72 : 96), - iconBox.heightAnchor.constraint(equalToConstant: thumbnail == nil ? 84 : 104), - preview.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor), - preview.centerYAnchor.constraint(equalTo: iconBox.centerYAnchor, constant: thumbnail == nil ? -6 : -8), - preview.widthAnchor.constraint(lessThanOrEqualToConstant: thumbnail == nil ? 32 : 80), - preview.heightAnchor.constraint(lessThanOrEqualToConstant: thumbnail == nil ? 36 : 72), - extensionPill.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor), - extensionPill.bottomAnchor.constraint(equalTo: iconBox.bottomAnchor, constant: -10), + cover.centerXAnchor.constraint(equalTo: container.centerXAnchor), + cover.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 10 : 12), + cover.widthAnchor.constraint(equalToConstant: coverSize.width), + cover.heightAnchor.constraint(equalToConstant: coverSize.height), + textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + textStack.topAnchor.constraint(equalTo: cover.bottomAnchor, constant: layout.isCompact ? 8 : 10), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8), title.widthAnchor.constraint(equalTo: textStack.widthAnchor), location.widthAnchor.constraint(equalTo: textStack.widthAnchor) ]) return container } + private var filePreviewCoverSize: NSSize { + if layout == .expanded { + return NSSize(width: 148, height: 126) + } + if layout.isCompact { + return NSSize(width: 104, height: 74) + } + return NSSize(width: 118, height: 86) + } + + private func documentCoverPreviewView(for item: ClipboardItem, extensionText: String, accent: NSColor) -> NSView { + let cover = NSView() + cover.wantsLayer = true + cover.layer?.cornerRadius = layout.isCompact ? 8 : 10 + cover.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.74).cgColor + cover.layer?.borderWidth = 0.8 + cover.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.14).cgColor + cover.layer?.shadowColor = NSColor.black.cgColor + cover.layer?.shadowOpacity = 0.10 + cover.layer?.shadowRadius = 7 + cover.layer?.shadowOffset = NSSize(width: 0, height: 3) + cover.translatesAutoresizingMaskIntoConstraints = false + + let band = NSView() + band.wantsLayer = true + band.layer?.cornerRadius = layout.isCompact ? 4 : 5 + band.layer?.backgroundColor = accent.withAlphaComponent(0.82).cgColor + band.translatesAutoresizingMaskIntoConstraints = false + + let fold = NSView() + fold.wantsLayer = true + fold.layer?.cornerRadius = layout.isCompact ? 4 : 5 + fold.layer?.backgroundColor = accent.withAlphaComponent(0.16).cgColor + fold.layer?.borderWidth = 0.6 + fold.layer?.borderColor = accent.withAlphaComponent(0.18).cgColor + fold.translatesAutoresizingMaskIntoConstraints = false + + let iconName = item.kind == .pdf ? "doc.richtext.fill" : "doc.fill" + let icon = headerIcon(iconName, color: accent) + icon.translatesAutoresizingMaskIntoConstraints = false + + let lineStack = NSStackView(views: [ + previewLine(color: accent.withAlphaComponent(0.36)), + previewLine(color: NSColor.labelColor.withAlphaComponent(0.13)), + previewLine(color: NSColor.labelColor.withAlphaComponent(0.09)) + ]) + lineStack.orientation = .vertical + lineStack.alignment = .leading + lineStack.spacing = layout.isCompact ? 4 : 5 + lineStack.translatesAutoresizingMaskIntoConstraints = false + for (index, line) in lineStack.arrangedSubviews.enumerated() { + line.heightAnchor.constraint(equalToConstant: index == 0 ? 6 : 5).isActive = true + } + + let extensionPill = capsuleLabel(extensionText, color: accent) + extensionPill.translatesAutoresizingMaskIntoConstraints = false + + cover.addSubview(band) + cover.addSubview(fold) + cover.addSubview(icon) + cover.addSubview(lineStack) + cover.addSubview(extensionPill) + + NSLayoutConstraint.activate([ + band.leadingAnchor.constraint(equalTo: cover.leadingAnchor, constant: layout.isCompact ? 9 : 10), + band.trailingAnchor.constraint(equalTo: cover.trailingAnchor, constant: layout.isCompact ? -9 : -10), + band.topAnchor.constraint(equalTo: cover.topAnchor, constant: layout.isCompact ? 8 : 9), + band.heightAnchor.constraint(equalToConstant: layout.isCompact ? 7 : 8), + fold.trailingAnchor.constraint(equalTo: cover.trailingAnchor, constant: layout.isCompact ? -8 : -10), + fold.topAnchor.constraint(equalTo: cover.topAnchor, constant: layout.isCompact ? 20 : 22), + fold.widthAnchor.constraint(equalToConstant: layout.isCompact ? 18 : 21), + fold.heightAnchor.constraint(equalToConstant: layout.isCompact ? 18 : 21), + icon.leadingAnchor.constraint(equalTo: cover.leadingAnchor, constant: layout.isCompact ? 14 : 16), + icon.topAnchor.constraint(equalTo: band.bottomAnchor, constant: layout.isCompact ? 13 : 15), + icon.widthAnchor.constraint(equalToConstant: layout.isCompact ? 26 : 30), + icon.heightAnchor.constraint(equalToConstant: layout.isCompact ? 29 : 33), + lineStack.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: layout.isCompact ? 9 : 11), + lineStack.trailingAnchor.constraint(equalTo: cover.trailingAnchor, constant: layout.isCompact ? -13 : -16), + lineStack.centerYAnchor.constraint(equalTo: icon.centerYAnchor), + lineStack.arrangedSubviews[0].widthAnchor.constraint(equalTo: lineStack.widthAnchor), + lineStack.arrangedSubviews[1].widthAnchor.constraint(equalTo: lineStack.widthAnchor, multiplier: 0.78), + lineStack.arrangedSubviews[2].widthAnchor.constraint(equalTo: lineStack.widthAnchor, multiplier: 0.56), + extensionPill.centerXAnchor.constraint(equalTo: cover.centerXAnchor), + extensionPill.bottomAnchor.constraint(equalTo: cover.bottomAnchor, constant: layout.isCompact ? -7 : -9), + extensionPill.widthAnchor.constraint(lessThanOrEqualTo: cover.widthAnchor, constant: -18) + ]) + return cover + } + private func multiFilePreviewView(for item: ClipboardItem) -> NSView { let container = NSView() container.wantsLayer = true @@ -4094,7 +8211,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let tile = NSView() tile.wantsLayer = true tile.layer?.cornerRadius = 10 - tile.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.84).cgColor + tile.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.64).cgColor tile.layer?.borderWidth = 0.8 tile.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor tile.layer?.shadowColor = NSColor.black.cgColor @@ -4143,28 +8260,49 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private func audioPreviewView(for item: ClipboardItem) -> NSView { let container = NSView() + let accent = accentColor(for: item.kind) container.wantsLayer = true - container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor + container.layer?.backgroundColor = accent.withAlphaComponent(0.10).cgColor container.translatesAutoresizingMaskIntoConstraints = false - let note = headerIcon("music.note", color: accentColor(for: item.kind)) - note.translatesAutoresizingMaskIntoConstraints = false + let artworkSize = audioArtworkSize + #if DEBUG + debugAudioPreviewChrome = "album-card" + debugAudioArtworkSize = artworkSize + debugAudioLabelPlacement = "below-artwork" + #endif - let waveform = NSStackView() - waveform.orientation = .horizontal - waveform.alignment = .centerY - waveform.spacing = 5 - waveform.translatesAutoresizingMaskIntoConstraints = false - for height in [12, 22, 16, 30, 22, 26, 14] as [CGFloat] { - let bar = NSView() - bar.wantsLayer = true - bar.layer?.cornerRadius = 2.5 - bar.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.55).cgColor - bar.translatesAutoresizingMaskIntoConstraints = false - bar.widthAnchor.constraint(equalToConstant: 5).isActive = true - bar.heightAnchor.constraint(equalToConstant: height).isActive = true - waveform.addArrangedSubview(bar) - } + let artwork = NSView() + artwork.wantsLayer = true + artwork.layer?.cornerRadius = layout.isCompact ? 14 : 16 + artwork.layer?.backgroundColor = accent.cgColor + artwork.layer?.borderWidth = 1 + artwork.layer?.borderColor = NSColor.white.withAlphaComponent(0.28).cgColor + artwork.layer?.shadowColor = NSColor.black.cgColor + artwork.layer?.shadowOpacity = 0.16 + artwork.layer?.shadowRadius = 10 + artwork.layer?.shadowOffset = NSSize(width: 0, height: 4) + artwork.translatesAutoresizingMaskIntoConstraints = false + + let disk = NSView() + disk.wantsLayer = true + disk.layer?.cornerRadius = artworkSize * 0.28 + disk.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor + disk.layer?.borderWidth = 0.8 + disk.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + disk.translatesAutoresizingMaskIntoConstraints = false + + let centerDot = NSView() + centerDot.wantsLayer = true + centerDot.layer?.cornerRadius = 4 + centerDot.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.46).cgColor + centerDot.translatesAutoresizingMaskIntoConstraints = false + + let note = headerIcon("music.note", color: .white) + note.translatesAutoresizingMaskIntoConstraints = false + artwork.addSubview(disk) + artwork.addSubview(centerDot) + artwork.addSubview(note) let title = NSTextField(labelWithString: titleText(for: item)) title.font = .systemFont(ofSize: 14, weight: .semibold) @@ -4186,47 +8324,94 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { labels.spacing = 3 labels.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(note) - container.addSubview(waveform) + container.addSubview(artwork) container.addSubview(labels) NSLayoutConstraint.activate([ - note.centerXAnchor.constraint(equalTo: container.centerXAnchor), - note.topAnchor.constraint(equalTo: container.topAnchor, constant: 18), - note.widthAnchor.constraint(equalToConstant: 28), - note.heightAnchor.constraint(equalToConstant: 28), - waveform.centerXAnchor.constraint(equalTo: container.centerXAnchor), - waveform.topAnchor.constraint(equalTo: note.bottomAnchor, constant: 8), + artwork.centerXAnchor.constraint(equalTo: container.centerXAnchor), + artwork.topAnchor.constraint(equalTo: container.topAnchor, constant: audioArtworkTopInset), + artwork.widthAnchor.constraint(equalToConstant: artworkSize), + artwork.heightAnchor.constraint(equalToConstant: artworkSize), + disk.centerXAnchor.constraint(equalTo: artwork.centerXAnchor), + disk.centerYAnchor.constraint(equalTo: artwork.centerYAnchor), + disk.widthAnchor.constraint(equalToConstant: artworkSize * 0.56), + disk.heightAnchor.constraint(equalToConstant: artworkSize * 0.56), + centerDot.centerXAnchor.constraint(equalTo: artwork.centerXAnchor), + centerDot.centerYAnchor.constraint(equalTo: artwork.centerYAnchor), + centerDot.widthAnchor.constraint(equalToConstant: 8), + centerDot.heightAnchor.constraint(equalToConstant: 8), + note.centerXAnchor.constraint(equalTo: artwork.centerXAnchor, constant: 1), + note.centerYAnchor.constraint(equalTo: artwork.centerYAnchor, constant: -1), + note.widthAnchor.constraint(equalToConstant: artworkSize * 0.32), + note.heightAnchor.constraint(equalToConstant: artworkSize * 0.32), labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), - labels.topAnchor.constraint(equalTo: waveform.bottomAnchor, constant: 10), + labels.topAnchor.constraint(equalTo: artwork.bottomAnchor, constant: audioLabelTopGap), title.widthAnchor.constraint(equalTo: labels.widthAnchor), detail.widthAnchor.constraint(equalTo: labels.widthAnchor) ]) return container } + private var audioArtworkSize: CGFloat { + if layout == .expanded { + return 96 + } + return layout.isCompact ? 62 : 72 + } + + private var audioArtworkTopInset: CGFloat { + layout.isCompact ? 14 : 16 + } + + private var audioLabelTopGap: CGFloat { + layout.isCompact ? 10 : 12 + } + private func videoPreviewView(for item: ClipboardItem) -> NSView { let container = NSView() + let accent = accentColor(for: item.kind) container.wantsLayer = true - container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor + container.layer?.backgroundColor = accent.withAlphaComponent(0.10).cgColor container.translatesAutoresizingMaskIntoConstraints = false + let frameHeight = videoPreviewFrameHeight + #if DEBUG + debugVideoPreviewChrome = "player-card" + debugVideoFrameHeight = frameHeight + debugVideoFormatPlacement = "bottom-center" + #endif + let frame = NSView() frame.wantsLayer = true frame.layer?.cornerRadius = 14 - frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor + frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.82).cgColor frame.layer?.borderWidth = 1 frame.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor + frame.layer?.shadowColor = NSColor.black.cgColor + frame.layer?.shadowOpacity = 0.12 + frame.layer?.shadowRadius = 8 + frame.layer?.shadowOffset = NSSize(width: 0, height: 3) frame.translatesAutoresizingMaskIntoConstraints = false - let film = headerIcon("film", color: .white) + let film = headerIcon("film", color: NSColor.white.withAlphaComponent(0.58)) film.translatesAutoresizingMaskIntoConstraints = false + + let playBadge = NSView() + playBadge.wantsLayer = true + playBadge.layer?.cornerRadius = 20 + playBadge.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor + playBadge.layer?.borderWidth = 0.8 + playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.28).cgColor + playBadge.translatesAutoresizingMaskIntoConstraints = false + let play = headerIcon("play.fill", color: .white) play.translatesAutoresizingMaskIntoConstraints = false - frame.addSubview(film) - frame.addSubview(play) + playBadge.addSubview(play) - let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accentColor(for: item.kind)) + frame.addSubview(film) + frame.addSubview(playBadge) + + let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accent) extensionPill.translatesAutoresizingMaskIntoConstraints = false frame.addSubview(extensionPill) @@ -4256,16 +8441,20 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { frame.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), frame.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), frame.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16), - frame.heightAnchor.constraint(equalToConstant: layout.isCompact ? 82 : 92), - film.centerXAnchor.constraint(equalTo: frame.centerXAnchor), - film.centerYAnchor.constraint(equalTo: frame.centerYAnchor), - film.widthAnchor.constraint(equalToConstant: 38), - film.heightAnchor.constraint(equalToConstant: 38), - play.centerXAnchor.constraint(equalTo: frame.centerXAnchor, constant: 1), - play.centerYAnchor.constraint(equalTo: frame.centerYAnchor), + frame.heightAnchor.constraint(equalToConstant: frameHeight), + film.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: 12), + film.topAnchor.constraint(equalTo: frame.topAnchor, constant: 10), + film.widthAnchor.constraint(equalToConstant: 22), + film.heightAnchor.constraint(equalToConstant: 22), + playBadge.centerXAnchor.constraint(equalTo: frame.centerXAnchor), + playBadge.centerYAnchor.constraint(equalTo: frame.centerYAnchor, constant: -4), + playBadge.widthAnchor.constraint(equalToConstant: 40), + playBadge.heightAnchor.constraint(equalToConstant: 40), + play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1), + play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor), play.widthAnchor.constraint(equalToConstant: 16), play.heightAnchor.constraint(equalToConstant: 16), - extensionPill.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -10), + extensionPill.centerXAnchor.constraint(equalTo: frame.centerXAnchor), extensionPill.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -10), labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), @@ -4277,61 +8466,126 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } + private var videoPreviewFrameHeight: CGFloat { + if layout == .expanded { + return 124 + } + return layout.isCompact ? 82 : 92 + } + private func colorPreviewView(for item: ClipboardItem) -> NSView { let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind) - let textColor = ColorPayload.contrastingTextColor(for: swatchColor) + 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 - container.layer?.backgroundColor = swatchColor.cgColor + container.layer?.backgroundColor = swatchColor.withAlphaComponent(0.10).cgColor container.translatesAutoresizingMaskIntoConstraints = false - let hex = NSTextField(labelWithString: ColorPayload.displayHex(from: item.payload)) - hex.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 19 : 22, weight: .bold) - hex.textColor = textColor + let shadowHost = NSView() + shadowHost.wantsLayer = true + shadowHost.layer?.cornerRadius = layout.isCompact ? 12 : 14 + shadowHost.layer?.shadowColor = NSColor.black.cgColor + shadowHost.layer?.shadowOpacity = 0.11 + shadowHost.layer?.shadowRadius = 8 + shadowHost.layer?.shadowOffset = NSSize(width: 0, height: 3) + shadowHost.translatesAutoresizingMaskIntoConstraints = false + + let chip = NSView() + chip.wantsLayer = true + chip.layer?.cornerRadius = layout.isCompact ? 12 : 14 + chip.layer?.masksToBounds = true + chip.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.72).cgColor + chip.layer?.borderWidth = 0.8 + chip.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.14).cgColor + chip.translatesAutoresizingMaskIntoConstraints = false + + let swatch = NSView() + swatch.wantsLayer = true + 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: ColorPayload.componentSummary(from: item.payload)) - components.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) - components.textColor = textColor.withAlphaComponent(0.72) + 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 labels = NSStackView(views: [hex, components]) - labels.orientation = .vertical - labels.alignment = .centerX - labels.spacing = 6 - labels.translatesAutoresizingMaskIntoConstraints = false + let labelStack = NSStackView(views: [hex, components]) + labelStack.orientation = .vertical + labelStack.alignment = .centerX + labelStack.spacing = layout.isCompact ? 2 : 3 + labelStack.translatesAutoresizingMaskIntoConstraints = false - let outline = NSView() - outline.wantsLayer = true - outline.layer?.cornerRadius = 16 - outline.layer?.borderWidth = 1 - outline.layer?.borderColor = textColor.withAlphaComponent(0.24).cgColor - outline.layer?.backgroundColor = NSColor.white.withAlphaComponent(textColor == .white ? 0.10 : 0.26).cgColor - outline.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(outline) - outline.addSubview(labels) + container.addSubview(shadowHost) + shadowHost.addSubview(chip) + chip.addSubview(swatch) + chip.addSubview(labelStack) NSLayoutConstraint.activate([ - outline.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), - outline.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), - outline.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 24 : 28), - outline.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -24 : -28), - labels.leadingAnchor.constraint(equalTo: outline.leadingAnchor, constant: 12), - labels.trailingAnchor.constraint(equalTo: outline.trailingAnchor, constant: -12), - labels.centerYAnchor.constraint(equalTo: outline.centerYAnchor), - hex.widthAnchor.constraint(equalTo: labels.widthAnchor), - components.widthAnchor.constraint(equalTo: labels.widthAnchor) + shadowHost.centerXAnchor.constraint(equalTo: container.centerXAnchor), + shadowHost.centerYAnchor.constraint(equalTo: container.centerYAnchor), + shadowHost.widthAnchor.constraint(equalToConstant: chipSize.width), + shadowHost.heightAnchor.constraint(equalToConstant: chipSize.height), + chip.leadingAnchor.constraint(equalTo: shadowHost.leadingAnchor), + chip.trailingAnchor.constraint(equalTo: shadowHost.trailingAnchor), + chip.topAnchor.constraint(equalTo: shadowHost.topAnchor), + chip.bottomAnchor.constraint(equalTo: shadowHost.bottomAnchor), + swatch.leadingAnchor.constraint(equalTo: chip.leadingAnchor), + swatch.trailingAnchor.constraint(equalTo: chip.trailingAnchor), + swatch.topAnchor.constraint(equalTo: chip.topAnchor), + swatch.heightAnchor.constraint(equalToConstant: colorPreviewSwatchHeight), + labelStack.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 12), + labelStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -12), + labelStack.topAnchor.constraint(equalTo: swatch.bottomAnchor, constant: layout.isCompact ? 7 : 9), + labelStack.bottomAnchor.constraint(lessThanOrEqualTo: chip.bottomAnchor, constant: -8), + hex.widthAnchor.constraint(equalTo: labelStack.widthAnchor), + components.widthAnchor.constraint(equalTo: labelStack.widthAnchor) ]) return container } + private var colorPreviewChipSize: NSSize { + if layout == .expanded { + return NSSize(width: 260, height: 162) + } + if layout.isCompact { + return NSSize(width: 196, height: 98) + } + return NSSize(width: 230, height: 112) + } + + private var colorPreviewSwatchHeight: CGFloat { + if layout == .expanded { + return 94 + } + return layout.isCompact ? 52 : 62 + } + + private var colorPreviewHexFontSize: CGFloat { + if layout == .expanded { + return 20 + } + return layout.isCompact ? 15 : 17 + } + private func codePreviewView(for item: ClipboardItem) -> NSView { let container = NSView() container.wantsLayer = true @@ -4341,7 +8595,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let editor = NSView() editor.wantsLayer = true editor.layer?.cornerRadius = 10 - editor.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.88).cgColor + editor.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.66).cgColor editor.layer?.borderWidth = 0.8 editor.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.22).cgColor editor.translatesAutoresizingMaskIntoConstraints = false @@ -4415,7 +8669,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let imageView = AspectFillImageView(image: thumbnail) imageView.translatesAutoresizingMaskIntoConstraints = false - let overlay = capsuleLabel(mediaMetricText(for: thumbnail), color: NSColor.black.withAlphaComponent(0.60)) + 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 container.addSubview(imageView) @@ -4425,7 +8684,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), imageView.topAnchor.constraint(equalTo: container.topAnchor), imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - overlay.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + overlay.centerXAnchor.constraint(equalTo: container.centerXAnchor), overlay.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10) ]) return container @@ -4435,6 +8694,12 @@ 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 @@ -4469,7 +8734,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor), play.widthAnchor.constraint(equalToConstant: 16), play.heightAnchor.constraint(equalToConstant: 16), - extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + extensionPill.centerXAnchor.constraint(equalTo: container.centerXAnchor), extensionPill.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10) ]) return container @@ -4623,12 +8888,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func iconBadge(for item: ClipboardItem) -> NSView { - let badge = NSView() - badge.wantsLayer = true - badge.layer?.cornerRadius = headerBadgeCornerRadius - badge.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.95).cgColor - badge.layer?.borderWidth = 1 - badge.layer?.borderColor = NSColor.white.withAlphaComponent(0.52).cgColor + let badge = HeaderBadgeTileView( + cornerRadius: headerBadgeCornerRadius, + maskedCorners: CornerMasks.badgeWithoutBottomRight + ) badge.translatesAutoresizingMaskIntoConstraints = false if let bundleId = item.sourceAppBundleId, let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) { @@ -4638,12 +8901,25 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path)) icon.imageScaling = .scaleProportionallyUpOrDown icon.translatesAutoresizingMaskIntoConstraints = false - badge.addSubview(icon) + let iconClipView = NSView() + iconClipView.wantsLayer = true + iconClipView.layer?.cornerRadius = headerBadgeCornerRadius + iconClipView.layer?.maskedCorners = CornerMasks.badgeWithoutBottomRight + iconClipView.layer?.masksToBounds = true + iconClipView.translatesAutoresizingMaskIntoConstraints = false + headerBadgeContentView = icon + badge.addSubview(iconClipView) + iconClipView.addSubview(icon) + let iconBleed = headerBadgeAppIconBleed NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: headerBadgeIconInset), - icon.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -headerBadgeIconInset), - icon.topAnchor.constraint(equalTo: badge.topAnchor, constant: headerBadgeIconInset), - icon.bottomAnchor.constraint(equalTo: badge.bottomAnchor, constant: -headerBadgeIconInset) + iconClipView.leadingAnchor.constraint(equalTo: badge.leadingAnchor), + iconClipView.trailingAnchor.constraint(equalTo: badge.trailingAnchor), + iconClipView.topAnchor.constraint(equalTo: badge.topAnchor), + iconClipView.bottomAnchor.constraint(equalTo: badge.bottomAnchor), + icon.leadingAnchor.constraint(equalTo: iconClipView.leadingAnchor, constant: -iconBleed), + icon.trailingAnchor.constraint(equalTo: iconClipView.trailingAnchor, constant: iconBleed), + icon.topAnchor.constraint(equalTo: iconClipView.topAnchor, constant: -iconBleed), + icon.bottomAnchor.constraint(equalTo: iconClipView.bottomAnchor, constant: iconBleed) ]) } else if let monogram = Self.sourceMonogram(from: itemSourceAppName) { #if DEBUG @@ -4657,6 +8933,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { label.maximumNumberOfLines = 1 label.setAccessibilityLabel(itemSourceAppName ?? monogram) label.translatesAutoresizingMaskIntoConstraints = false + headerBadgeContentView = label badge.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: headerBadgeIconInset), @@ -4675,8 +8952,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { icon.imageScaling = .scaleProportionallyUpOrDown icon.contentTintColor = accentColor(for: item.kind) icon.translatesAutoresizingMaskIntoConstraints = false + headerBadgeContentView = icon badge.addSubview(icon) - let symbolInset = headerBadgeIconInset + 2 + let symbolInset = headerBadgeIconInset + 1 NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: symbolInset), icon.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -symbolInset), @@ -4806,16 +9084,107 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } } + private func compactMetricText(for item: ClipboardItem) -> String { + switch item.kind { + case .text, .url, .code, .color, .unknown: + let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload + return compactCharacterMetric(text.count) + case .richText: + return compactCharacterMetric(item.displayText.count) + case .file: + if let byteCount = fileByteCount(paths: FilePayload.paths(from: item.payload)) { + return compactByteMetric(byteCount) + } + return compactCharacterMetric(item.payload.count) + case .pdf, .audio, .video: + if let byteCount = fileByteCount(paths: [item.payload]) { + return compactByteMetric(byteCount) + } + return compactCharacterMetric(item.payload.count) + case .image: + let path = item.imagePath?.clipboardTrimmed.isEmpty == false ? item.imagePath! : item.payload + if let byteCount = fileByteCount(paths: [path]) { + return compactByteMetric(byteCount) + } + return compactCharacterMetric(item.payload.count) + } + } + + private func compactCharacterMetric(_ count: Int) -> String { + if count == 1 { + return "1 char" + } + if count < 1_000 { + return "\(max(0, count)) chars" + } + if count < 1_000_000 { + return "\(compactDecimal(Double(count) / 1_000))k chars" + } + return "\(compactDecimal(Double(count) / 1_000_000))M chars" + } + + private func compactByteMetric(_ byteCount: Int64) -> String { + let bytes = max(0, byteCount) + if bytes < 1_024 { + return "\(bytes) B" + } + if bytes < 1_024 * 1_024 { + return "\(compactDecimal(Double(bytes) / 1_024)) KB" + } + if bytes < 1_024 * 1_024 * 1_024 { + return "\(compactDecimal(Double(bytes) / (1_024 * 1_024))) MB" + } + return "\(compactDecimal(Double(bytes) / (1_024 * 1_024 * 1_024))) GB" + } + + private func compactDecimal(_ value: Double) -> String { + if value < 10 { + let rounded = (value * 10).rounded() / 10 + return rounded == floor(rounded) ? "\(Int(rounded))" : String(format: "%.1f", rounded) + } + return "\(Int(value.rounded()))" + } + + private func fileByteCount(paths: [String]) -> Int64? { + let byteCounts = paths.compactMap { path -> Int64? in + let trimmed = path.clipboardTrimmed + guard !trimmed.isEmpty else { return nil } + let url = FilePayload.fileURL(from: trimmed) + guard let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber else { + return nil + } + return size.int64Value + } + guard !byteCounts.isEmpty else { return nil } + return byteCounts.reduce(0, +) + } + private func footerSourceText(for item: ClipboardItem) -> String? { let source = item.sourceApp?.clipboardTrimmed + let device = sourceDeviceText(for: item) let usage = usageText(for: item.useCount) - if let source, !source.isEmpty, let usage { - return "\(source) - \(usage)" - } + + var parts: [String] = [] if let source, !source.isEmpty { - return source + if let device { + parts.append("\(source) on \(device)") + } else { + parts.append(source) + } + } else if let device { + parts.append(device) } - return usage + if let usage { + parts.append(usage) + } + return parts.isEmpty ? nil : parts.joined(separator: " - ") + } + + private func sourceDeviceText(for item: ClipboardItem) -> String? { + guard let device = ClipboardItem.normalizedDeviceName(item.sourceDeviceName) else { return nil } + let localDevice = ClipboardItem.localDeviceName + guard device.caseInsensitiveCompare(localDevice) != .orderedSame else { return nil } + return device } private func usageText(for useCount: Int) -> String? { @@ -5190,7 +9559,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func accessibilityHelpText() -> String { - "Press Return to paste. Press Space for Quick Look." + "Press Return to paste. Space opens Quick Look. Command-R renames; Command-E edits text. Delete removes selected clips; Command-Z restores them." } private func row(_ views: [NSView]) -> NSStackView { diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 8e47c64..2ebd1d6 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -1,20 +1,67 @@ import Foundation import AppKit +enum ClipboardSelectionMode { + case replace + case toggle + case range + case hover + case activate +} + +struct ClipboardCollectionCountSummary { + fileprivate let sortModeCounts: [Int: Int] + fileprivate let collectionCounts: [String: Int] + + func count(for sortMode: ClipboardSortMode) -> Int { + sortModeCounts[sortMode.rawValue] ?? 0 + } + + func count(named name: String) -> Int { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return 0 } + return collectionCounts[normalizedName.lowercased()] ?? 0 + } +} + +struct ClipboardCategorySelectionSnapshot: Equatable { + let sortMode: ClipboardSortMode + let selectedCollectionName: String? + let isStackFilterSelected: Bool + let selectedSortModeFilterRawValues: Set + let selectedCollectionNameFilters: [String] + let selectedItemID: UUID? + let selectedItemIDs: [UUID] + let selectionAnchorItemID: UUID? + let selectedIndex: Int +} + final class ClipboardPanelViewModel { private static let searchDateFormatter: DateFormatter = { let formatter = DateFormatter() - formatter.calendar = Calendar.searchCalendar + let calendar = Calendar.searchCalendar + formatter.calendar = calendar formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd" return formatter }() + private static let searchLocale = Locale(identifier: "en_US_POSIX") + + private struct StructuredFilterMatcher { + let tokens: [String] + let exactValue: String? + } + + private struct StructuredFilterValue { + let value: String + let isExact: Bool + } private struct ParsedSearchQuery { var textTokens: [String] = [] - var appTokenGroups: [[String]] = [] - var collectionTokenGroups: [[String]] = [] + var appTokenGroups: [StructuredFilterMatcher] = [] + var deviceTokenGroups: [StructuredFilterMatcher] = [] + var collectionTokenGroups: [StructuredFilterMatcher] = [] var typeKinds: Set = [] var createdAfter: Date? var createdBefore: Date? @@ -23,6 +70,7 @@ final class ClipboardPanelViewModel { var isEmpty: Bool { textTokens.isEmpty && appTokenGroups.isEmpty + && deviceTokenGroups.isEmpty && collectionTokenGroups.isEmpty && typeKinds.isEmpty && createdAfter == nil @@ -31,22 +79,92 @@ final class ClipboardPanelViewModel { } } + private struct VisibleItemsCacheKey: Hashable { + let query: String + let sortMode: Int + let collectionNameKey: String? + let sortModeFilterRawValues: [Int] + let collectionNameFilterKeys: [String] + } + + private struct CategoryFilterSelection: Hashable { + let sortModeRawValues: [Int] + let collectionNameKeys: [String] + + static let empty = CategoryFilterSelection(sortModeRawValues: [], collectionNameKeys: []) + + var isEmpty: Bool { + sortModeRawValues.isEmpty && collectionNameKeys.isEmpty + } + + var totalCount: Int { + sortModeRawValues.count + collectionNameKeys.count + } + } + + private struct IndexedClipboardItem { + let offset: Int + let item: ClipboardItem + } + + private enum VisibleItemsSortOrdering { + case recency + case useCount + case lastUsed + case original + } + private(set) var visibleItems: [ClipboardItem] = [] { - didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } } + didSet { + var nextItemByID: [UUID: ClipboardItem] = [:] + nextItemByID.reserveCapacity(visibleItems.count) + var nextIndexByID: [UUID: Int] = [:] + nextIndexByID.reserveCapacity(visibleItems.count) + for (index, item) in visibleItems.enumerated() { + if nextItemByID[item.id] == nil { + nextItemByID[item.id] = item + } + if nextIndexByID[item.id] == nil { + nextIndexByID[item.id] = index + } + } + visibleItemByID = nextItemByID + visibleIndexByID = nextIndexByID + guard !isRecomputingVisibleItems else { return } + notifyVisibleItemsChanged() + } } var searchText: String = "" { didSet { guard oldValue != searchText else { return } + guard !isBatchingFilterSelectionChanges else { return } + guard Self.normalizedSearchValue(oldValue) != Self.normalizedSearchValue(searchText) else { + notifyMain { self.onSearchTextChanged?(self.searchText) } + return + } selectedItemID = selectedItem?.id recomputeVisibleItems() + notifyMain { self.onSearchTextChanged?(self.searchText) } } } var sortMode: ClipboardSortMode { didSet { guard oldValue != sortMode else { return } + guard !isBatchingFilterSelectionChanges else { + if !suppressDefaultSortModePersistence { + settings.defaultSortMode = sortMode + } + return + } + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) + isBatchingFilterSelectionChanges = true isStackFilterSelected = false selectedCollectionName = nil - settings.defaultSortMode = sortMode + isBatchingFilterSelectionChanges = false + if !suppressDefaultSortModePersistence { + settings.defaultSortMode = sortMode + } recomputeVisibleItems() onSortModeChanged?(sortMode) } @@ -54,6 +172,7 @@ final class ClipboardPanelViewModel { private(set) var selectedCollectionName: String? { didSet { guard oldValue != selectedCollectionName else { return } + guard !isBatchingFilterSelectionChanges else { return } recomputeVisibleItems() onCollectionsChanged?() } @@ -61,53 +180,127 @@ final class ClipboardPanelViewModel { private(set) var isStackFilterSelected = false { didSet { guard oldValue != isStackFilterSelected else { return } + guard !isBatchingFilterSelectionChanges else { return } recomputeVisibleItems() onStackChanged?() } } + private(set) var isStackCaptureEnabled = false { + didSet { + guard oldValue != isStackCaptureEnabled else { return } + if !isStackCaptureEnabled, isStackFilterSelected, stackItemIDs.isEmpty { + isStackFilterSelected = false + } + notifyMain { self.onStackChanged?() } + } + } var selectedIndex: Int = 0 { didSet { guard oldValue != selectedIndex else { return } notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) } } } + private(set) var selectedItemIDs: [UUID] = [] { + didSet { + selectedItemIDSet = Set(selectedItemIDs) + guard oldValue != selectedItemIDs else { return } + notifyMain { self.onSelectedItemsChanged?() } + } + } private(set) var statusMessage: String = "" { didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } } } - private var items: [ClipboardItem] = [] + private var items: [ClipboardItem] = [] { + didSet { + rebuildItemIndexes() + collectionNamesCache = nil + visibleItemsCache.removeAll(keepingCapacity: true) + collectionCountCache = nil + } + } private let store: ClipboardStore private let settings: SettingsModel private let cacheService: ClipboardCacheService private let pasteService: PasteActionService + private let imageTextExtractor: (NSImage) -> String? private var selectedItemID: UUID? + private var selectionAnchorItemID: UUID? + private var isRecomputingVisibleItems = false + private var isBatchingFilterSelectionChanges = false + private var selectedSortModeFilterRawValues: Set = [] + private var selectedCollectionNameFilters: [String] = [] + private var suppressDefaultSortModePersistence = false + private var isApplyingLocalCollectionMutation = false + private var isDeferringLocalStoreRecompute = false + private var pendingDeletionUndo: [ClipboardStoreRemoval] = [] + private var collectionCountCache: (query: String, summary: ClipboardCollectionCountSummary)? + private var collectionNamesCache: [String]? + private var visibleItemsCache: [VisibleItemsCacheKey: [ClipboardItem]] = [:] + private var itemByID: [UUID: ClipboardItem] = [:] + private var itemIDSet: Set = [] + private var indexedItemsByCollectionKey: [String: [IndexedClipboardItem]] = [:] + private var indexedItemsBySortMode: [Int: [IndexedClipboardItem]] = [:] + private var assignedCollectionNamesByKey: [String: String] = [:] + private var visibleItemByID: [UUID: ClipboardItem] = [:] + private var visibleIndexByID: [UUID: Int] = [:] + private var selectedItemIDSet: Set = [] + private var stackItemIDSet: Set = [] private var stackItemIDs: [UUID] = [] { didSet { + stackItemIDSet = Set(stackItemIDs) guard oldValue != stackItemIDs else { return } notifyMain { self.onStackChanged?() } } } + + deinit { + purgePendingDeletionUndo() + } + var targetApplicationProvider: () -> NSRunningApplication? = { nil } var willPasteToTarget: () -> Void = {} var onVisibleItemsChanged: (([ClipboardItem]) -> Void)? + var onSearchTextChanged: ((String) -> Void)? var onSelectedIndexChanged: ((Int) -> Void)? + var onSelectedItemsChanged: (() -> Void)? var onStatusMessageChanged: ((String) -> Void)? var onSortModeChanged: ((ClipboardSortMode) -> Void)? var onCollectionsChanged: (() -> Void)? var onStackChanged: (() -> Void)? var onCaptureStatusChanged: (() -> Void)? + var onCompactModeChanged: (() -> Void)? + var onPanelLayoutChanged: (() -> Void)? - init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) { + #if DEBUG + private(set) var debugVisibleItemsFullScanCount = 0 + private(set) var debugVisibleItemsIndexedLookupCount = 0 + private(set) var debugCollectionCountFullScanCount = 0 + private(set) var debugCollectionCountIndexedLookupCount = 0 + #endif + + init( + store: ClipboardStore, + settings: SettingsModel, + cacheService: ClipboardCacheService, + imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:) + ) { self.store = store self.settings = settings self.cacheService = cacheService self.sortMode = settings.defaultSortMode self.pasteService = PasteActionService(cacheService: cacheService) + self.imageTextExtractor = imageTextExtractor store.observeItems { [weak self] list in - self?.notifyMain { - self?.items = list - self?.recomputeVisibleItems() + guard let self else { return } + let shouldDeferRecompute = self.isDeferringLocalStoreRecompute + self.notifyMain { + self.items = list + if shouldDeferRecompute { + return + } + self.recomputeVisibleItems() } } settings.observe { [weak self] change in @@ -118,8 +311,22 @@ final class ClipboardPanelViewModel { self?.onStatusMessageChanged?("") self?.onCaptureStatusChanged?() case .collections: + guard let self else { return } + self.collectionNamesCache = nil + guard !self.isApplyingLocalCollectionMutation else { return } + self.recomputeVisibleItems() + self.onCollectionsChanged?() + case .compactMode: + self?.onCompactModeChanged?() + case .panelLayout: + self?.onPanelLayoutChanged?() + case .defaultSortMode: + guard let self else { return } + self.sortMode = self.settings.defaultSortMode + case .includeImageTextInSearch: + self?.collectionCountCache = nil + self?.visibleItemsCache.removeAll(keepingCapacity: true) self?.recomputeVisibleItems() - self?.onCollectionsChanged?() default: break } @@ -127,11 +334,24 @@ final class ClipboardPanelViewModel { } } + #if DEBUG + func debugResetVisibleItemsPerformanceCounters() { + debugVisibleItemsFullScanCount = 0 + debugVisibleItemsIndexedLookupCount = 0 + debugCollectionCountFullScanCount = 0 + debugCollectionCountIndexedLookupCount = 0 + } + #endif + var selectedItem: ClipboardItem? { guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil } return visibleItems[selectedIndex] } + var selectedItemCount: Int { + selectedItemsInSelectionOrder().count + } + var canShowSelectedInClipboard: Bool { selectedItem != nil && canShowVisibleItemsInClipboard } @@ -141,6 +361,7 @@ final class ClipboardPanelViewModel { && (!searchText.clipboardTrimmed.isEmpty || sortMode != .mostRecent || selectedCollectionName != nil + || !activeCategoryFilterSelection().isEmpty || isStackFilterSelected) } @@ -156,12 +377,24 @@ final class ClipboardPanelViewModel { "Stack" } + var isCompactModeEnabled: Bool { + false + } + + var panelLayout: ClipboardPanelLayout { + .vertical + } + + func toggleCompactMode() { + statusMessage = "Compact Mode was removed" + } + var collectionNames: [String] { - let assignedNames = Set( - items.compactMap { item -> String? in - ClipboardCollectionDefaults.normalizedName(item.collectionName) - } - ) + if let collectionNamesCache { + return collectionNamesCache + } + + let assignedNames = Set(assignedCollectionNamesByKey.values) let configuredNames = settings.customCollectionNames let configuredNameSet = Set(configuredNames.map { $0.lowercased() }) let allNames = assignedNames.union(configuredNames) @@ -175,17 +408,95 @@ final class ClipboardPanelViewModel { .filter { !ClipboardCollectionDefaults.names.contains($0) } .filter { !configuredNameSet.contains($0.lowercased()) } .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - return defaultNames + configuredCustomNames + assignedCustomNames + let names = defaultNames + configuredCustomNames + assignedCustomNames + collectionNamesCache = names + return names + } + + var searchFilterSourceAppNames: [String] { + uniqueSearchFacetValues(items.compactMap(\.sourceApp)) + } + + var searchFilterDeviceNames: [String] { + uniqueSearchFacetValues(items.map { Optional($0.effectiveSourceDeviceName) }) } func collectionCount(for sortMode: ClipboardSortMode) -> Int { - let query = searchText.clipboardTrimmed.lowercased() - return computeVisibleItems(from: items, query: query, sortMode: sortMode).count + collectionCountSummary().count(for: sortMode) } func collectionCount(named name: String) -> Int { + collectionCountSummary().count(named: name) + } + + func collectionCountSummary() -> ClipboardCollectionCountSummary { let query = searchText.clipboardTrimmed.lowercased() - return computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: name).count + if let collectionCountCache, collectionCountCache.query == query { + return collectionCountCache.summary + } + + let parsedQuery = parseSearchQuery(query) + var sortModeCounts = Dictionary(uniqueKeysWithValues: ClipboardSortMode.allCases.map { ($0.rawValue, 0) }) + var collectionCounts: [String: Int] = [:] + + if parsedQuery.isEmpty { + for mode in ClipboardSortMode.allCases { + sortModeCounts[mode.rawValue] = indexedItemsBySortMode[mode.rawValue]?.count ?? 0 + } + for (collectionKey, indexedItems) in indexedItemsByCollectionKey { + collectionCounts[collectionKey] = indexedItems.count + } + #if DEBUG + debugCollectionCountIndexedLookupCount += 1 + #endif + } else { + for item in items { + if !matchesSearchQuery(item, query: parsedQuery) { + continue + } + + for mode in ClipboardSortMode.allCases where mode.includes(item) { + sortModeCounts[mode.rawValue, default: 0] += 1 + } + if let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) { + collectionCounts[collectionName.lowercased(), default: 0] += 1 + } + } + #if DEBUG + debugCollectionCountFullScanCount += 1 + #endif + } + + let summary = ClipboardCollectionCountSummary( + sortModeCounts: sortModeCounts, + collectionCounts: collectionCounts + ) + collectionCountCache = (query, summary) + return summary + } + + func shouldShowCategory(for sortMode: ClipboardSortMode) -> Bool { + collectionCount(for: sortMode) > 0 || isSortModeCategorySelected(sortMode) + } + + func isSortModeCategorySelected(_ mode: ClipboardSortMode) -> Bool { + guard !isStackFilterSelected else { return false } + let selection = activeCategoryFilterSelection() + if !selection.isEmpty { + return selection.sortModeRawValues.contains(mode.rawValue) + } + return selectedCollectionName == nil && sortMode == mode + } + + func isCollectionCategorySelected(named name: String) -> Bool { + guard !isStackFilterSelected else { return false } + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return false } + let key = normalizedName.lowercased() + let selection = activeCategoryFilterSelection() + if !selection.isEmpty { + return selection.collectionNameKeys.contains(key) + } + return selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame } var captureStatusMessage: String { @@ -196,29 +507,58 @@ final class ClipboardPanelViewModel { cacheService.previewThumbnail(for: item) } - func selectItem(at index: Int) { + func selectItem(at index: Int, mode: ClipboardSelectionMode = .replace) { guard index >= 0 && index < visibleItems.count else { return } - selectedIndex = index + let item = visibleItems[index] + switch mode { + case .replace: + setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id) + case .toggle: + var nextIDs = selectedItemIDs.isEmpty ? selectedItem.map { [$0.id] } ?? [] : selectedItemIDs + if nextIDs.contains(item.id) { + if nextIDs.count > 1 { + nextIDs.removeAll { $0 == item.id } + } + } else { + nextIDs.append(item.id) + } + setActiveSelection(item, at: index, selectedIDs: nextIDs, anchorID: item.id) + case .range: + let anchorIndex = selectionAnchorItemID + .flatMap { anchorID in visibleItems.firstIndex { $0.id == anchorID } } + ?? selectedIndex + let lower = min(anchorIndex, index) + let upper = max(anchorIndex, index) + let rangeIDs = visibleItems[lower...upper].map(\.id) + let anchorID = visibleItems[anchorIndex].id + setActiveSelection(item, at: index, selectedIDs: rangeIDs, anchorID: anchorID) + case .hover: + setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id) + case .activate: + if selectedItemIDs.contains(item.id), selectedItemIDs.count > 1 { + setActiveSelection(item, at: index, selectedIDs: selectedItemIDs, anchorID: selectionAnchorItemID ?? item.id) + } else { + setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id) + } + } } func selectFirstItem() { guard !visibleItems.isEmpty else { return } - selectedItemID = nil - if selectedIndex == 0 { + let previousIndex = selectedIndex + setIndexBasedSelection(at: 0) + if previousIndex == 0 { notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) } - } else { - selectedIndex = 0 } } func selectLastItem() { guard !visibleItems.isEmpty else { return } - selectedItemID = nil let lastIndex = visibleItems.count - 1 - if selectedIndex == lastIndex { + let previousIndex = selectedIndex + setIndexBasedSelection(at: lastIndex) + if previousIndex == lastIndex { notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) } - } else { - selectedIndex = lastIndex } } @@ -226,10 +566,35 @@ final class ClipboardPanelViewModel { let count = visibleItems.count guard count > 0 else { return } let target = max(0, min(count - 1, selectedIndex + delta)) - selectedIndex = target + setIndexBasedSelection(at: target) + } + + func selectAllVisibleItems() { + guard !visibleItems.isEmpty else { + selectedItemIDs = [] + statusMessage = "No clips to select" + return + } + + let activeIndex = max(0, min(visibleItems.count - 1, selectedIndex)) + let activeItem = visibleItems[activeIndex] + selectedItemID = activeItem.id + selectionAnchorItemID = activeItem.id + selectedItemIDs = visibleItems.map(\.id) + if selectedIndex == activeIndex { + notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) } + } else { + selectedIndex = activeIndex + } + let noun = visibleItems.count == 1 ? "clip" : "clips" + statusMessage = "Selected \(visibleItems.count) \(noun)" } func pasteSelected() { + if selectedItemCount > 1 { + pasteSelectedItems() + return + } guard let item = selectedItem else { return } let result = pasteService.paste(item, targetApp: targetApplicationProvider()) if case .pasted = result { @@ -244,6 +609,10 @@ final class ClipboardPanelViewModel { } func pasteSelectedPlainText() { + if selectedItemCount > 1 { + pasteSelectedItemsAsText() + return + } guard let item = selectedItem else { return } let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider()) if case .pastedPlainText = result { @@ -270,6 +639,10 @@ final class ClipboardPanelViewModel { } func copySelected() { + if selectedItemCount > 1 { + copySelectedItems() + return + } guard let item = selectedItem else { return } let result = pasteService.copy(item) if case .failed = result {} else { @@ -281,6 +654,10 @@ final class ClipboardPanelViewModel { } func copySelectedPlainText() { + if selectedItemCount > 1 { + copySelectedItemsAsText() + return + } guard let item = selectedItem else { return } let result = pasteService.copyPlainText(item) if case .failed = result {} else { @@ -293,13 +670,18 @@ final class ClipboardPanelViewModel { func isItemStacked(at index: Int) -> Bool { guard index >= 0 && index < visibleItems.count else { return false } - return stackItemIDs.contains(visibleItems[index].id) + return stackItemIDSet.contains(visibleItems[index].id) + } + + func isItemSelected(at index: Int) -> Bool { + guard index >= 0 && index < visibleItems.count else { return false } + return selectedItemIDSet.contains(visibleItems[index].id) } func toggleSelectedStackMembership() { guard let item = selectedItem else { return } - if let existingIndex = stackItemIDs.firstIndex(of: item.id) { - stackItemIDs.remove(at: existingIndex) + if stackItemIDs.contains(item.id) { + consumeStackItem(item.id) statusMessage = "Removed from Stack" return } @@ -315,10 +697,9 @@ final class ClipboardPanelViewModel { return } - let existingIDs = Set(stackItemIDs) let newIDs = visibleItems .map(\.id) - .filter { !existingIDs.contains($0) } + .filter { !stackItemIDSet.contains($0) } guard !newIDs.isEmpty else { statusMessage = "Visible clips are already in Stack" return @@ -329,10 +710,42 @@ final class ClipboardPanelViewModel { statusMessage = "Added \(newIDs.count) \(noun) to Stack" } + func toggleStackCaptureMode() { + isStackCaptureEnabled.toggle() + statusMessage = isStackCaptureEnabled ? "Stack capture is on" : "Stack capture is off" + } + + func addCapturedItemToStack(_ item: ClipboardItem) { + guard isStackCaptureEnabled else { return } + pruneStackItems() + stackItemIDs.append(item.id) + if isStackFilterSelected { + selectedItemID = item.id + selectionAnchorItemID = item.id + selectedItemIDs = [item.id] + recomputeVisibleItems() + } + let noun = stackItemIDs.count == 1 ? "clip" : "clips" + statusMessage = "Captured to Stack (\(stackItemIDs.count) \(noun))" + } + func selectStack() { - guard !stackItemIDs.isEmpty else { return } + guard !stackItemIDs.isEmpty || isStackCaptureEnabled else { return } + let collectionChanged = selectedCollectionName != nil + || !selectedSortModeFilterRawValues.isEmpty + || !selectedCollectionNameFilters.isEmpty + let stackChanged = !isStackFilterSelected + guard collectionChanged || stackChanged else { return } + isBatchingFilterSelectionChanges = true selectedCollectionName = nil + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) isStackFilterSelected = true + isBatchingFilterSelectionChanges = false + recomputeVisibleItems() + if collectionChanged { + onCollectionsChanged?() + } } func clearStackSelection() { @@ -396,6 +809,75 @@ final class ClipboardPanelViewModel { handleStackPlainTextActionResult(result, items: package.items) } + func addSelectedItemsToStack() { + pruneStackItems() + let selectedItems = selectedItemsInSelectionOrder() + guard !selectedItems.isEmpty else { + statusMessage = "No selected clips to stack" + return + } + + let newIDs = selectedItems + .map(\.id) + .filter { !stackItemIDSet.contains($0) } + guard !newIDs.isEmpty else { + statusMessage = "Selected clips are already in Stack" + return + } + + stackItemIDs.append(contentsOf: newIDs) + let noun = newIDs.count == 1 ? "clip" : "clips" + 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) + } + + 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) + } + func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { guard index >= 0 && index < visibleItems.count else { return [] } return pasteService.pasteboardWriters(for: visibleItems[index]) @@ -418,6 +900,78 @@ final class ClipboardPanelViewModel { return item.customTitle ?? "" } + @discardableResult + func createTextClip(_ text: String, now: Date = Date()) -> ClipboardItem? { + let trimmed = text.clipboardTrimmed + guard !trimmed.isEmpty else { + statusMessage = "Text clip cannot be empty" + return nil + } + + let kind: ClipboardItemKind = CodeSnippetPayload.isLikelyCode(trimmed) ? .code : .text + let displayText = kind == .code ? CodeSnippetPayload.title(from: trimmed) : trimmed + let collectionName = selectedCollectionName.flatMap(ClipboardCollectionDefaults.normalizedName) + let payloadHash = store.hashString(trimmed) + let item = ClipboardItem( + id: UUID(), + kind: kind, + displayText: displayText, + payload: trimmed, + payloadHash: payloadHash, + createdAt: now, + lastUsedAt: now, + useCount: 0, + sourceApp: AppConfiguration.appName, + imagePath: nil, + thumbnailPath: nil, + isPinned: false, + sourceAppBundleId: Bundle.main.bundleIdentifier, + collectionName: collectionName + ) + + let selectedID = settings.pruneDuplicates + ? items.first(where: { $0.payloadHash == payloadHash })?.id ?? item.id + : item.id + selectedItemID = selectedID + selectionAnchorItemID = selectedID + selectedItemIDs = [selectedID] + + let searchChanged = !searchText.clipboardTrimmed.isEmpty + let stackChanged = isStackFilterSelected + let targetSortMode: ClipboardSortMode? = collectionName == nil && !sortMode.includes(item) + ? (kind == .code ? .code : .text) + : nil + + isBatchingFilterSelectionChanges = true + if stackChanged { + isStackFilterSelected = false + } + if searchChanged { + searchText = "" + } + if let targetSortMode { + sortMode = targetSortMode + } + isBatchingFilterSelectionChanges = false + + let storedItem = store.upsert(item) + if searchChanged { + notifyMain { self.onSearchTextChanged?(self.searchText) } + } + if let targetSortMode { + onSortModeChanged?(targetSortMode) + } + if store.items.contains(where: { $0.id == storedItem.id }) { + selectedItemID = storedItem.id + selectionAnchorItemID = storedItem.id + selectedItemIDs = [storedItem.id] + statusMessage = kind == .code ? "Created code clip" : "Created text clip" + return storedItem + } + statusMessage = kind == .code ? "Created code clip" : "Created text clip" + return item + } + func updateSelectedTitle(to title: String) { guard let item = selectedItem else { return } let normalizedTitle = ClipboardItem.normalizedCustomTitle(title) @@ -449,15 +1003,82 @@ final class ClipboardPanelViewModel { } } + func rotateSelectedImageClockwise() { + guard let item = selectedItem, item.kind == .image else { return } + let imagePath = item.imagePath ?? item.payload + guard let data = cacheService.data(for: imagePath), + let image = NSImage(data: data), + let rotated = image.rotatedClockwise(), + let fullData = rotated.pngData(), + let thumbnailData = rotated.resized(to: CGSize(width: 320, height: 320)).pngData(), + let fullPath = cacheService.cacheImageSidecarData(fullData, id: item.id), + let thumbnailPath = cacheService.cacheImageSidecarData(thumbnailData, id: item.id, fileNamePrefix: "thumb") else { + statusMessage = "Could not rotate image" + return + } + + selectedItemID = item.id + let payloadHash = store.hashData(fullData) + if store.updateImage( + item.id, + imagePath: fullPath, + thumbnailPath: thumbnailPath, + payloadHash: payloadHash + ) { + statusMessage = "Rotated image" + } else { + statusMessage = "Could not rotate image" + } + } + + func extractTextFromSelectedImage() { + guard let item = selectedItem, item.kind == .image else { return } + let imagePath = item.imagePath ?? item.payload + guard let data = cacheService.data(for: imagePath), + let image = NSImage(data: data) else { + statusMessage = "Could not read image" + return + } + + guard let text = ImageTextExtractor.normalizedRecognizedText(imageTextExtractor(image)) else { + statusMessage = "No text found in image" + return + } + + selectedItemID = item.id + if store.updateImageText(item.id, ocrText: text) { + statusMessage = "Extracted text from image" + } else { + statusMessage = "Could not save image text" + } + } + func previewURLForSelected() -> URL? { guard let item = selectedItem else { return nil } return previewURL(for: item) } + func linkPreviewRequestForSelected() -> LinkPreviewRequest? { + guard let item = selectedItem else { return nil } + return linkPreviewRequest(for: item) + } + internal func previewURL(for item: ClipboardItem) -> URL? { cacheService.temporaryPreviewURL(for: item) } + internal func linkPreviewRequest(for item: ClipboardItem) -> LinkPreviewRequest? { + guard item.kind == .url, + let url = URL(string: item.payload.clipboardTrimmed), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + + let title = item.displayText.clipboardTrimmed.isEmpty ? url.absoluteString : item.displayText + return LinkPreviewRequest(url: url, title: title) + } + func openSelected() { guard let item = selectedItem else { return } switch item.kind { @@ -514,10 +1135,88 @@ final class ClipboardPanelViewModel { } func deleteSelected() { - guard let item = selectedItem else { return } - store.remove(item.id) - let next = max(0, min(visibleItems.count - 2, selectedIndex)) - selectedIndex = next + let selectedItems = selectedItemsInSelectionOrder() + guard !selectedItems.isEmpty else { return } + + purgePendingDeletionUndo() + let originalVisibleCount = visibleItems.count + let firstDeletedVisibleIndex = selectedItems + .compactMap { item in visibleItems.firstIndex { $0.id == item.id } } + .min() ?? selectedIndex + + let removalOrder = selectedItems.sorted { lhs, rhs in + let lhsIndex = items.firstIndex { $0.id == lhs.id } ?? -1 + let rhsIndex = items.firstIndex { $0.id == rhs.id } ?? -1 + return lhsIndex > rhsIndex + } + let removals = removalOrder.compactMap { item in + store.remove(item.id, purgeManagedCache: false) + } + guard !removals.isEmpty else { return } + + pendingDeletionUndo = removals + let removedIDs = Set(removals.map(\.item.id)) + stackItemIDs.removeAll { removedIDs.contains($0) } + + if visibleItems.isEmpty { + selectedIndex = 0 + selectedItemID = nil + selectionAnchorItemID = nil + selectedItemIDs = [] + } else { + let remainingVisibleCount = originalVisibleCount - removals.count + let nextIndex = max(0, min(visibleItems.count - 1, min(firstDeletedVisibleIndex, remainingVisibleCount))) + setIndexBasedSelection(at: nextIndex) + } + + let noun = removals.count == 1 ? "clip" : "clips" + statusMessage = "Deleted \(removals.count) \(noun)" + } + + func clearHistory(since cutoff: Date) { + purgePendingDeletionUndo() + let ids = items.filter { $0.createdAt >= cutoff }.map(\.id) + guard !ids.isEmpty else { + finishClearHistory(removedCount: 0) + return + } + isDeferringLocalStoreRecompute = true + for id in ids { + store.remove(id) + } + isDeferringLocalStoreRecompute = false + recomputeVisibleItems() + finishClearHistory(removedCount: ids.count) + } + + func undoLastDelete() { + guard !pendingDeletionUndo.isEmpty else { + statusMessage = "Nothing to undo" + return + } + + let removals = pendingDeletionUndo + pendingDeletionUndo = [] + store.restore(removals) + + let restoredIDs = removals + .sorted(by: { $0.index < $1.index }) + .map(\.item.id) + let visibleRestoredIDs = restoredIDs.filter { id in + visibleItems.contains { $0.id == id } + } + if let firstRestoredID = visibleRestoredIDs.first, + let restoredIndex = visibleItems.firstIndex(where: { $0.id == firstRestoredID }) { + setActiveSelection( + visibleItems[restoredIndex], + at: restoredIndex, + selectedIDs: visibleRestoredIDs, + anchorID: firstRestoredID + ) + } + + let noun = removals.count == 1 ? "clip" : "clips" + statusMessage = "Restored \(removals.count) \(noun)" } func togglePinSelected() { @@ -562,17 +1261,316 @@ final class ClipboardPanelViewModel { } settings.ignoredItemKindsRaw.append(item.kind.rawValue) - statusMessage = "Ignored \(Self.statusKindName(item.kind)) items for future captures" + if settings.ignoredItemKindsRaw.contains(item.kind.rawValue) { + statusMessage = "Ignored \(Self.statusKindName(item.kind)) items for future captures" + } else { + statusMessage = "At least one content type must stay enabled." + } + } + + func categorySelectionSnapshot() -> ClipboardCategorySelectionSnapshot { + ClipboardCategorySelectionSnapshot( + sortMode: sortMode, + selectedCollectionName: selectedCollectionName, + isStackFilterSelected: isStackFilterSelected, + selectedSortModeFilterRawValues: selectedSortModeFilterRawValues, + selectedCollectionNameFilters: selectedCollectionNameFilters, + selectedItemID: selectedItemID, + selectedItemIDs: selectedItemIDs, + selectionAnchorItemID: selectionAnchorItemID, + selectedIndex: selectedIndex + ) + } + + func previewSortMode(_ mode: ClipboardSortMode) { + applyCategorySelectionSnapshot( + ClipboardCategorySelectionSnapshot( + sortMode: mode, + selectedCollectionName: nil, + isStackFilterSelected: false, + selectedSortModeFilterRawValues: [], + selectedCollectionNameFilters: [], + selectedItemID: selectedItemID, + selectedItemIDs: selectedItemIDs, + selectionAnchorItemID: selectionAnchorItemID, + selectedIndex: selectedIndex + ), + persistDefaultSortMode: false, + restoreItemSelection: false + ) + } + + func previewCollection(named name: String) { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return } + applyCategorySelectionSnapshot( + ClipboardCategorySelectionSnapshot( + sortMode: sortMode, + selectedCollectionName: normalizedName, + isStackFilterSelected: false, + selectedSortModeFilterRawValues: [], + selectedCollectionNameFilters: [], + selectedItemID: selectedItemID, + selectedItemIDs: selectedItemIDs, + selectionAnchorItemID: selectionAnchorItemID, + selectedIndex: selectedIndex + ), + persistDefaultSortMode: false, + restoreItemSelection: false + ) + } + + func previewStack() { + applyCategorySelectionSnapshot( + ClipboardCategorySelectionSnapshot( + sortMode: sortMode, + selectedCollectionName: nil, + isStackFilterSelected: true, + selectedSortModeFilterRawValues: [], + selectedCollectionNameFilters: [], + selectedItemID: selectedItemID, + selectedItemIDs: selectedItemIDs, + selectionAnchorItemID: selectionAnchorItemID, + selectedIndex: selectedIndex + ), + persistDefaultSortMode: false, + restoreItemSelection: false + ) + } + + func restoreCategorySelection(_ snapshot: ClipboardCategorySelectionSnapshot) { + applyCategorySelectionSnapshot(snapshot, persistDefaultSortMode: false, restoreItemSelection: true) + } + + func commitCategorySelection() { + settings.defaultSortMode = sortMode + } + + private func applyCategorySelectionSnapshot( + _ snapshot: ClipboardCategorySelectionSnapshot, + persistDefaultSortMode: Bool, + restoreItemSelection: Bool + ) { + let categoryChanged = sortMode != snapshot.sortMode + || selectedCollectionName != snapshot.selectedCollectionName + || isStackFilterSelected != snapshot.isStackFilterSelected + || selectedSortModeFilterRawValues != snapshot.selectedSortModeFilterRawValues + || selectedCollectionNameFilters != snapshot.selectedCollectionNameFilters + let selectionChanged = restoreItemSelection && ( + selectedItemID != snapshot.selectedItemID + || selectedItemIDs != snapshot.selectedItemIDs + || selectionAnchorItemID != snapshot.selectionAnchorItemID + || selectedIndex != snapshot.selectedIndex + ) + guard categoryChanged || selectionChanged else { + if persistDefaultSortMode { + settings.defaultSortMode = sortMode + } + return + } + + let previousSortMode = sortMode + let previousStackSelection = isStackFilterSelected + let previousBatching = isBatchingFilterSelectionChanges + let previousPersistenceSuppression = suppressDefaultSortModePersistence + isBatchingFilterSelectionChanges = true + suppressDefaultSortModePersistence = !persistDefaultSortMode + isStackFilterSelected = snapshot.isStackFilterSelected + selectedCollectionName = snapshot.selectedCollectionName + selectedSortModeFilterRawValues = snapshot.selectedSortModeFilterRawValues + selectedCollectionNameFilters = snapshot.selectedCollectionNameFilters + sortMode = snapshot.sortMode + isBatchingFilterSelectionChanges = previousBatching + suppressDefaultSortModePersistence = previousPersistenceSuppression + + if restoreItemSelection { + selectedItemID = snapshot.selectedItemID + selectedItemIDs = snapshot.selectedItemIDs + selectionAnchorItemID = snapshot.selectionAnchorItemID + selectedIndex = snapshot.selectedIndex + } + + if persistDefaultSortMode { + settings.defaultSortMode = sortMode + } + + recomputeVisibleItems() + if previousSortMode != sortMode { + onSortModeChanged?(sortMode) + } else { + onSortModeChanged?(sortMode) + } + onCollectionsChanged?() + if previousStackSelection != isStackFilterSelected { + onStackChanged?() + } + } + + func selectSortMode(_ mode: ClipboardSortMode, extending: Bool = false) { + if extending { + toggleSortModeCategoryFilter(mode) + return + } + let categoryFiltersChanged = !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty + let stackChanged = isStackFilterSelected + let collectionChanged = selectedCollectionName != nil + if categoryFiltersChanged || stackChanged || collectionChanged { + isBatchingFilterSelectionChanges = true + isStackFilterSelected = false + selectedCollectionName = nil + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) + sortMode = mode + isBatchingFilterSelectionChanges = false + settings.defaultSortMode = mode + recomputeVisibleItems() + onSortModeChanged?(mode) + onCollectionsChanged?() + return + } + sortMode = mode } func selectCollection(named name: String) { + selectCollection(named: name, extending: false) + } + + func selectCollection(named name: String, extending: Bool) { guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return } + if extending { + toggleCollectionCategoryFilter(named: normalizedName) + return + } + let stackChanged = isStackFilterSelected + let collectionChanged = selectedCollectionName != normalizedName + let categoryFiltersChanged = !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty + guard stackChanged || collectionChanged || categoryFiltersChanged else { return } + isBatchingFilterSelectionChanges = true isStackFilterSelected = false + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) selectedCollectionName = normalizedName + isBatchingFilterSelectionChanges = false + recomputeVisibleItems() + } + + private func toggleSortModeCategoryFilter(_ mode: ClipboardSortMode) { + var sortModeRawValues = selectedSortModeFilterRawValues + var collectionNames = selectedCollectionNameFilters + let seeded = seedCategoryFiltersIfNeeded(sortModeRawValues: &sortModeRawValues, collectionNames: &collectionNames) + if seeded, mode != .mostRecent, sortModeRawValues == Set([ClipboardSortMode.mostRecent.rawValue]) { + sortModeRawValues.remove(ClipboardSortMode.mostRecent.rawValue) + } + + if sortModeRawValues.contains(mode.rawValue) { + sortModeRawValues.remove(mode.rawValue) + } else { + sortModeRawValues.insert(mode.rawValue) + } + applyCategoryFilters(sortModeRawValues: sortModeRawValues, collectionNames: collectionNames) + } + + private func toggleCollectionCategoryFilter(named name: String) { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return } + var sortModeRawValues = selectedSortModeFilterRawValues + var collectionNames = selectedCollectionNameFilters + let seeded = seedCategoryFiltersIfNeeded(sortModeRawValues: &sortModeRawValues, collectionNames: &collectionNames) + if seeded, sortModeRawValues == Set([ClipboardSortMode.mostRecent.rawValue]) { + sortModeRawValues.remove(ClipboardSortMode.mostRecent.rawValue) + } + + if let existingIndex = collectionNames.firstIndex(where: { $0.caseInsensitiveCompare(normalizedName) == .orderedSame }) { + collectionNames.remove(at: existingIndex) + } else { + collectionNames.append(normalizedName) + } + applyCategoryFilters(sortModeRawValues: sortModeRawValues, collectionNames: collectionNames) + } + + @discardableResult + private func seedCategoryFiltersIfNeeded(sortModeRawValues: inout Set, collectionNames: inout [String]) -> Bool { + guard sortModeRawValues.isEmpty && collectionNames.isEmpty else { return false } + if let selectedCollectionName { + collectionNames = [selectedCollectionName] + } else if !isStackFilterSelected { + sortModeRawValues = [sortMode.rawValue] + } + return true + } + + private func applyCategoryFilters(sortModeRawValues: Set, collectionNames: [String]) { + let normalizedCollectionNames = uniqueNormalizedCollectionNames(collectionNames) + if sortModeRawValues.isEmpty && normalizedCollectionNames.isEmpty { + let shouldManuallyRecompute = sortMode == .mostRecent + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) + isBatchingFilterSelectionChanges = true + isStackFilterSelected = false + selectedCollectionName = nil + isBatchingFilterSelectionChanges = false + sortMode = .mostRecent + if shouldManuallyRecompute { + recomputeVisibleItems() + onSortModeChanged?(sortMode) + onCollectionsChanged?() + } + statusMessage = "Selected Clipboard" + return + } + + isBatchingFilterSelectionChanges = true + isStackFilterSelected = false + selectedCollectionName = nil + selectedSortModeFilterRawValues = sortModeRawValues + selectedCollectionNameFilters = normalizedCollectionNames + isBatchingFilterSelectionChanges = false + recomputeVisibleItems() + onSortModeChanged?(sortMode) + onCollectionsChanged?() + + let count = activeCategoryFilterSelection().totalCount + let noun = count == 1 ? "category" : "categories" + statusMessage = "Filtered \(count) \(noun)" + } + + private func uniqueNormalizedCollectionNames(_ names: [String]) -> [String] { + var result: [String] = [] + var seen = Set() + for name in names { + guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { continue } + let key = normalizedName.lowercased() + guard seen.insert(key).inserted else { continue } + result.append(normalizedName) + } + return result + } + + func selectAdjacentCollection(delta: Int) { + guard delta != 0 else { return } + let names = collectionNames + guard !names.isEmpty else { + statusMessage = "No collections" + return + } + + let targetIndex: Int + if let selectedCollectionName, + let currentIndex = names.firstIndex(where: { $0.caseInsensitiveCompare(selectedCollectionName) == .orderedSame }) { + targetIndex = (currentIndex + delta + names.count) % names.count + } else { + targetIndex = delta > 0 ? 0 : names.count - 1 + } + + let targetName = names[targetIndex] + selectCollection(named: targetName) + statusMessage = "Selected \(targetName)" } func createCollection(named name: String, colorHex: String? = nil, selectAfterCreate: Bool = true) { - guard let normalizedName = settings.ensureCollection(named: name, colorHex: colorHex) else { return } + isApplyingLocalCollectionMutation = true + let createdName = settings.ensureCollection(named: name, colorHex: colorHex) + isApplyingLocalCollectionMutation = false + collectionNamesCache = nil + guard let normalizedName = createdName else { return } statusMessage = "Created \(normalizedName)" if selectAfterCreate { selectCollection(named: normalizedName) @@ -586,36 +1584,72 @@ final class ClipboardPanelViewModel { settings.collectionColorHex(forCollectionNamed: name) } + @discardableResult + func exportCollection(named name: String, to url: URL) throws -> ClipboardArchiveSummary { + let summary = try store.exportCollection(named: name, to: url) + if let normalizedName = ClipboardCollectionDefaults.normalizedName(name) { + let noun = summary.itemCount == 1 ? "clip" : "clips" + statusMessage = "Exported \(normalizedName) Pinboard with \(summary.itemCount) \(noun)" + } + return summary + } + + func reportCollectionExportFailure(_ error: Error) { + statusMessage = "Export failed: \(error.localizedDescription)" + } + func updateCollection(named currentName: String, to newName: String, colorHex: String? = nil) { - guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName), - let normalizedNewName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) else { + guard let normalizedCurrentName = ClipboardCollectionDefaults.normalizedName(currentName) else { return } + isApplyingLocalCollectionMutation = true + let updatedName = settings.updateCollection(named: normalizedCurrentName, to: newName, colorHex: colorHex) + isApplyingLocalCollectionMutation = false + collectionNamesCache = nil + guard let normalizedNewName = updatedName else { return } + + let selectionChanged = selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame + isDeferringLocalStoreRecompute = true for item in items where item.collectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame { store.setCollection(item.id, name: normalizedNewName) } - if selectedCollectionName?.caseInsensitiveCompare(normalizedCurrentName) == .orderedSame { + if selectionChanged { + isBatchingFilterSelectionChanges = true selectedCollectionName = normalizedNewName - } else { - recomputeVisibleItems() + isBatchingFilterSelectionChanges = false } + isDeferringLocalStoreRecompute = false + onCollectionsChanged?() + recomputeVisibleItems() statusMessage = "Updated \(normalizedNewName)" } func deleteCollection(named name: String) { - guard let normalizedName = settings.deleteCollection(named: name) else { return } + isApplyingLocalCollectionMutation = true + let deletedName = settings.deleteCollection(named: name) + isApplyingLocalCollectionMutation = false + collectionNamesCache = nil + guard let normalizedName = deletedName else { return } let matchingIDs = items .filter { $0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame } .map(\.id) + let selectionChanged = selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame + isDeferringLocalStoreRecompute = true for id in matchingIDs { store.remove(id) } - if selectedCollectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame { + if selectionChanged { + isBatchingFilterSelectionChanges = true selectedCollectionName = nil + selectedCollectionNameFilters.removeAll { $0.caseInsensitiveCompare(normalizedName) == .orderedSame } + isBatchingFilterSelectionChanges = false } else { - recomputeVisibleItems() + selectedCollectionNameFilters.removeAll { $0.caseInsensitiveCompare(normalizedName) == .orderedSame } } + isDeferringLocalStoreRecompute = false + onCollectionsChanged?() + recomputeVisibleItems() statusMessage = "Deleted \(normalizedName)" } @@ -627,18 +1661,40 @@ final class ClipboardPanelViewModel { guard canShowSelectedInClipboard, let item = selectedItem else { return } selectedItemID = item.id - if !searchText.isEmpty { + let searchChanged = !searchText.isEmpty + let stackChanged = isStackFilterSelected + let collectionChanged = selectedCollectionName != nil + || !selectedSortModeFilterRawValues.isEmpty + || !selectedCollectionNameFilters.isEmpty + let sortChanged = sortMode != .mostRecent + + isBatchingFilterSelectionChanges = true + if searchChanged { searchText = "" } - if isStackFilterSelected { + if stackChanged { isStackFilterSelected = false } - if selectedCollectionName != nil { + if collectionChanged { selectedCollectionName = nil + selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) + selectedCollectionNameFilters.removeAll(keepingCapacity: true) } - if sortMode != .mostRecent { + if sortChanged { sortMode = .mostRecent } + isBatchingFilterSelectionChanges = false + + recomputeVisibleItems() + if searchChanged { + notifyMain { self.onSearchTextChanged?(self.searchText) } + } + if sortChanged { + onSortModeChanged?(sortMode) + } + if collectionChanged { + onCollectionsChanged?() + } if let index = visibleItems.firstIndex(where: { $0.id == item.id }) { selectedIndex = index @@ -649,18 +1705,27 @@ final class ClipboardPanelViewModel { func recomputeVisibleItems() { pruneStackItems() + isRecomputingVisibleItems = true + defer { + isRecomputingVisibleItems = false + notifyVisibleItemsChanged() + } let previousSelection = selectedItemID + let previousSelectedIDs = selectedItemIDs let query = searchText.clipboardTrimmed.lowercased() if isStackFilterSelected { - let stackedItems = stackItemIDs.compactMap { id in - items.first { $0.id == id } - } + let stackedItems = stackItemIDs.compactMap { itemByID[$0] } visibleItems = computeStackVisibleItems(from: stackedItems, query: query) } else { - visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName) + visibleItems = cachedVisibleItems( + query: query, + sortMode: sortMode, + collectionName: selectedCollectionName, + categoryFilters: activeCategoryFilterSelection() + ) } - if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) { + if let selectedID = previousSelection, let index = visibleIndexByID[selectedID] { selectedIndex = index } else if selectedIndex >= visibleItems.count { selectedIndex = max(0, visibleItems.count - 1) @@ -673,19 +1738,158 @@ final class ClipboardPanelViewModel { } selectedItemID = selectedItem?.id - onCollectionsChanged?() + let visibleIDs = Set(visibleIndexByID.keys) + let visibleSelectedIDs = previousSelectedIDs.filter { visibleIDs.contains($0) } + if previousSelection == nil { + selectedItemIDs = selectedItemID.map { [$0] } ?? [] + } else if !visibleSelectedIDs.isEmpty { + selectedItemIDs = visibleSelectedIDs + } else if let selectedItemID { + selectedItemIDs = [selectedItemID] + } else { + selectedItemIDs = [] + } + if let selectionAnchorItemID, !visibleIDs.contains(selectionAnchorItemID) { + self.selectionAnchorItemID = selectedItemID + } + } + + private func notifyVisibleItemsChanged() { + notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } + } + + private func setActiveSelection( + _ item: ClipboardItem, + at index: Int, + selectedIDs: [UUID], + anchorID: UUID? + ) { + selectedItemID = item.id + selectionAnchorItemID = anchorID + selectedItemIDs = uniqueIDs(selectedIDs) + if selectedIndex == index { + notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) } + } else { + selectedIndex = index + } + } + + private func setIndexBasedSelection(at index: Int) { + guard index >= 0 && index < visibleItems.count else { return } + let item = visibleItems[index] + selectedItemID = nil + selectionAnchorItemID = item.id + selectedItemIDs = [item.id] + if selectedIndex != index { + selectedIndex = index + } + } + + private func uniqueIDs(_ ids: [UUID]) -> [UUID] { + var seen = Set() + return ids.filter { seen.insert($0).inserted } + } + + private func rebuildItemIndexes() { + var nextItemByID: [UUID: ClipboardItem] = [:] + nextItemByID.reserveCapacity(items.count) + var nextItemIDSet = Set() + nextItemIDSet.reserveCapacity(items.count) + var nextCollectionItems: [String: [IndexedClipboardItem]] = [:] + var nextAssignedCollectionNames: [String: String] = [:] + var nextSortModeItems: [Int: [IndexedClipboardItem]] = [:] + var allItems: [IndexedClipboardItem] = [] + allItems.reserveCapacity(items.count) + + for (offset, item) in items.enumerated() { + if nextItemByID[item.id] == nil { + nextItemByID[item.id] = item + } + nextItemIDSet.insert(item.id) + + let indexedItem = IndexedClipboardItem(offset: offset, item: item) + allItems.append(indexedItem) + + if let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) { + let collectionKey = collectionName.lowercased() + nextAssignedCollectionNames[collectionKey] = collectionName + nextCollectionItems[collectionKey, default: []].append(indexedItem) + } + + appendIndexedItem(indexedItem, for: item, to: &nextSortModeItems) + } + + nextSortModeItems[ClipboardSortMode.mostRecent.rawValue] = allItems + nextSortModeItems[ClipboardSortMode.mostUsed.rawValue] = allItems + itemByID = nextItemByID + itemIDSet = nextItemIDSet + indexedItemsByCollectionKey = nextCollectionItems + indexedItemsBySortMode = nextSortModeItems + assignedCollectionNamesByKey = nextAssignedCollectionNames + } + + private func appendIndexedItem( + _ indexedItem: IndexedClipboardItem, + for item: ClipboardItem, + to indexedItemsBySortMode: inout [Int: [IndexedClipboardItem]] + ) { + switch item.kind { + case .image: + indexedItemsBySortMode[ClipboardSortMode.images.rawValue, default: []].append(indexedItem) + case .url: + indexedItemsBySortMode[ClipboardSortMode.links.rawValue, default: []].append(indexedItem) + case .text, .richText: + indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem) + case .code: + indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem) + indexedItemsBySortMode[ClipboardSortMode.code.rawValue, default: []].append(indexedItem) + case .file, .pdf: + indexedItemsBySortMode[ClipboardSortMode.files.rawValue, default: []].append(indexedItem) + case .audio: + indexedItemsBySortMode[ClipboardSortMode.audio.rawValue, default: []].append(indexedItem) + case .color: + indexedItemsBySortMode[ClipboardSortMode.colors.rawValue, default: []].append(indexedItem) + case .video: + indexedItemsBySortMode[ClipboardSortMode.videos.rawValue, default: []].append(indexedItem) + case .unknown: + break + } + + if item.isPinned { + indexedItemsBySortMode[ClipboardSortMode.pinned.rawValue, default: []].append(indexedItem) + } + } + + 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] } ?? [] } private func nextStackItem() -> ClipboardItem? { pruneStackItems() guard let id = stackItemIDs.first else { return nil } - return items.first { $0.id == id } + return itemByID[id] } private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? { pruneStackItems() let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in - guard let item = items.first(where: { $0.id == id }), + guard let item = itemByID[id], let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else { return nil @@ -696,6 +1900,17 @@ final class ClipboardPanelViewModel { return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item)) } + 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 + } + 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 @@ -726,10 +1941,12 @@ final class ClipboardPanelViewModel { for item in items { store.markUsed(item.id) - consumeStackItem(item.id) + consumeStackItem(item.id, refreshActiveStackFilter: false) } if stackItemIDs.isEmpty { isStackFilterSelected = false + } else if isStackFilterSelected { + recomputeVisibleItems() } selectedItemID = items.first?.id let noun = items.count == 1 ? "clip" : "clips" @@ -746,168 +1963,340 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: statusMessage) } - private func consumeStackItem(_ id: UUID) { + 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)" + default: + statusMessage = 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 } stackItemIDs.remove(at: index) + if refreshActiveStackFilter && isStackFilterSelected { + recomputeVisibleItems() + } } private func pruneStackItems() { guard !stackItemIDs.isEmpty else { return } - let existingIDs = Set(items.map(\.id)) - let pruned = stackItemIDs.filter { existingIDs.contains($0) } + let pruned = stackItemIDs.filter { itemIDSet.contains($0) } if pruned != stackItemIDs { stackItemIDs = pruned } - if stackItemIDs.isEmpty && isStackFilterSelected { + if stackItemIDs.isEmpty && isStackFilterSelected && !isStackCaptureEnabled { isStackFilterSelected = false } } + private func purgePendingDeletionUndo() { + guard !pendingDeletionUndo.isEmpty else { return } + store.purgeManagedCacheReferences(for: pendingDeletionUndo) + pendingDeletionUndo = [] + } + + private func finishClearHistory(removedCount: Int) { + guard removedCount > 0 else { + statusMessage = "No clips to clear" + return + } + let noun = removedCount == 1 ? "clip" : "clips" + statusMessage = "Cleared \(removedCount) \(noun)" + } + private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] { let parsedQuery = parseSearchQuery(query) guard !parsedQuery.isEmpty else { return items } return items.filter { matchesSearchQuery($0, query: parsedQuery) } } + private func cachedVisibleItems( + query: String, + sortMode: ClipboardSortMode, + collectionName: String?, + categoryFilters: CategoryFilterSelection = .empty + ) -> [ClipboardItem] { + let collectionNameKey = ClipboardCollectionDefaults.normalizedName(collectionName)?.lowercased() + let key = VisibleItemsCacheKey( + query: query, + sortMode: sortMode.rawValue, + collectionNameKey: collectionNameKey, + sortModeFilterRawValues: categoryFilters.sortModeRawValues, + collectionNameFilterKeys: categoryFilters.collectionNameKeys + ) + if let cached = visibleItemsCache[key] { + return cached + } + + let computed: [ClipboardItem] + if query.isEmpty, + var indexedItems = indexedVisibleItems( + sortMode: sortMode, + collectionName: collectionName, + categoryFilters: categoryFilters + ) { + sortIndexedItems( + &indexedItems, + ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters) + ) + computed = indexedItems.map(\.item) + #if DEBUG + debugVisibleItemsIndexedLookupCount += 1 + #endif + } else { + computed = computeVisibleItems( + from: items, + query: query, + sortMode: sortMode, + collectionName: collectionName, + categoryFilters: categoryFilters + ) + #if DEBUG + debugVisibleItemsFullScanCount += 1 + #endif + } + if visibleItemsCache.count > 24 { + visibleItemsCache.removeAll(keepingCapacity: true) + } + visibleItemsCache[key] = computed + return computed + } + + private func indexedVisibleItems( + sortMode: ClipboardSortMode, + collectionName: String?, + categoryFilters: CategoryFilterSelection = .empty + ) -> [IndexedClipboardItem]? { + if !categoryFilters.isEmpty { + return indexedVisibleItems(matching: categoryFilters) + } + if let collectionKey = ClipboardCollectionDefaults.normalizedName(collectionName)?.lowercased() { + return indexedItemsByCollectionKey[collectionKey] ?? [] + } + return indexedItemsBySortMode[sortMode.rawValue] + } + + private func indexedVisibleItems(matching categoryFilters: CategoryFilterSelection) -> [IndexedClipboardItem] { + var indexedItemsByID: [UUID: IndexedClipboardItem] = [:] + + for rawValue in categoryFilters.sortModeRawValues { + for indexedItem in indexedItemsBySortMode[rawValue] ?? [] { + if indexedItemsByID[indexedItem.item.id] == nil { + indexedItemsByID[indexedItem.item.id] = indexedItem + } + } + } + + for collectionKey in categoryFilters.collectionNameKeys { + for indexedItem in indexedItemsByCollectionKey[collectionKey] ?? [] { + if indexedItemsByID[indexedItem.item.id] == nil { + indexedItemsByID[indexedItem.item.id] = indexedItem + } + } + } + + return Array(indexedItemsByID.values) + } + internal func computeVisibleItems( from items: [ClipboardItem], query: String, sortMode: ClipboardSortMode, collectionName: String? = nil + ) -> [ClipboardItem] { + computeVisibleItems( + from: items, + query: query, + sortMode: sortMode, + collectionName: collectionName, + categoryFilters: .empty + ) + } + + private func computeVisibleItems( + from items: [ClipboardItem], + query: String, + sortMode: ClipboardSortMode, + collectionName: String? = nil, + categoryFilters: CategoryFilterSelection = .empty ) -> [ClipboardItem] { let parsedQuery = parseSearchQuery(query) - let filtered = parsedQuery.isEmpty - ? items.enumerated().map { ($0.offset, $0.element) } - : items.enumerated().compactMap { index, item in - return matchesSearchQuery(item, query: parsedQuery) ? (index, item) : nil - } - let collectionFiltered: [(Int, ClipboardItem)] - if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) { - collectionFiltered = filtered.filter { - $0.1.collectionName?.caseInsensitiveCompare(collectionName) == .orderedSame + let normalizedCollectionName = ClipboardCollectionDefaults.normalizedName(collectionName) + var filtered: [IndexedClipboardItem] = [] + filtered.reserveCapacity(items.count) + + for (offset, item) in items.enumerated() { + if !parsedQuery.isEmpty, !matchesSearchQuery(item, query: parsedQuery) { + continue } - } else { - collectionFiltered = filtered - } - - func fallback(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool { - return lhs.0 < rhs.0 - } - - func sortByUsage(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool { - if lhs.1.lastUsedAt == rhs.1.lastUsedAt { - return fallback(lhs, rhs) - } - return lhs.1.lastUsedAt > rhs.1.lastUsedAt - } - - if ClipboardCollectionDefaults.normalizedName(collectionName) != nil { - return collectionFiltered - .sorted { - if $0.1.lastUsedAt == $1.1.lastUsedAt { - if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) } - return $0.1.createdAt > $1.1.createdAt - } - return $0.1.lastUsedAt > $1.1.lastUsedAt + if !categoryFilters.isEmpty { + guard itemMatchesCategoryFilters(item, filters: categoryFilters) else { + continue } - .map(\.1) + } else if let normalizedCollectionName { + guard item.collectionName?.caseInsensitiveCompare(normalizedCollectionName) == .orderedSame else { + continue + } + } else if !sortMode.includes(item) { + continue + } + filtered.append(IndexedClipboardItem(offset: offset, item: item)) + } + + sortIndexedItems( + &filtered, + ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters) + ) + return filtered.map(\.item) + } + + private func sortOrdering( + sortMode: ClipboardSortMode, + collectionName: String?, + categoryFilters: CategoryFilterSelection = .empty + ) -> VisibleItemsSortOrdering { + if categoryFilters.totalCount > 1 { + return .recency + } + if let rawValue = categoryFilters.sortModeRawValues.first, + let mode = ClipboardSortMode(rawValue: rawValue), + categoryFilters.collectionNameKeys.isEmpty { + return sortOrdering(sortMode: mode, collectionName: nil) + } + if !categoryFilters.collectionNameKeys.isEmpty { + return .recency + } + if ClipboardCollectionDefaults.normalizedName(collectionName) != nil { + return .recency } switch sortMode { - case .mostRecent: - return collectionFiltered - .sorted { - if $0.1.lastUsedAt == $1.1.lastUsedAt { - if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) } - return $0.1.createdAt > $1.1.createdAt - } - return $0.1.lastUsedAt > $1.1.lastUsedAt - } - .map(\.1) - + case .mostRecent, .pinned: + return .recency case .mostUsed: - return collectionFiltered - .sorted { - if $0.1.useCount == $1.1.useCount { return sortByUsage($0, $1) } - return $0.1.useCount > $1.1.useCount - } - .map(\.1) - - case .images: - return collectionFiltered - .filter { $0.1.kind == .image } - .sorted(by: sortByUsage) - .map(\.1) - - case .links: - return collectionFiltered - .filter { $0.1.kind == .url } - .sorted(by: sortByUsage) - .map(\.1) - - case .text: - return collectionFiltered - .filter { $0.1.kind == .text || $0.1.kind == .richText || $0.1.kind == .code } - .sorted(by: sortByUsage) - .map(\.1) - - case .files: - return collectionFiltered - .filter { $0.1.kind == .file || $0.1.kind == .pdf } - .sorted(by: sortByUsage) - .map(\.1) - - case .audio: - return collectionFiltered - .filter { $0.1.kind == .audio } - .sorted(by: sortByUsage) - .map(\.1) - - case .videos: - return collectionFiltered - .filter { $0.1.kind == .video } - .sorted(by: sortByUsage) - .map(\.1) - - case .colors: - return collectionFiltered - .filter { $0.1.kind == .color } - .sorted(by: sortByUsage) - .map(\.1) - - case .code: - return collectionFiltered - .filter { $0.1.kind == .code } - .sorted(by: sortByUsage) - .map(\.1) - - case .pinned: - return collectionFiltered - .filter { $0.1.isPinned } - .sorted { - if $0.1.lastUsedAt == $1.1.lastUsedAt { - if $0.1.createdAt == $1.1.createdAt { - return fallback($0, $1) - } - return $0.1.createdAt > $1.1.createdAt - } - return $0.1.lastUsedAt > $1.1.lastUsedAt - } - .map(\.1) - + return .useCount + case .images, .links, .text, .files, .audio, .videos, .colors, .code: + return .lastUsed @unknown default: - return collectionFiltered - .sorted(by: { lhs, rhs in - return fallback(lhs, rhs) - }) - .map(\.1) + return .original } } + private func activeCategoryFilterSelection() -> CategoryFilterSelection { + guard !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty else { + return .empty + } + let sortModeRawValues = selectedSortModeFilterRawValues.sorted() + let collectionKeys = selectedCollectionNameFilters + .compactMap { ClipboardCollectionDefaults.normalizedName($0)?.lowercased() } + .sorted() + return CategoryFilterSelection( + sortModeRawValues: sortModeRawValues, + collectionNameKeys: collectionKeys + ) + } + + private func itemMatchesCategoryFilters(_ item: ClipboardItem, filters: CategoryFilterSelection) -> Bool { + for rawValue in filters.sortModeRawValues { + if let mode = ClipboardSortMode(rawValue: rawValue), mode.includes(item) { + return true + } + } + + guard let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName)?.lowercased() else { + return false + } + return filters.collectionNameKeys.contains(collectionName) + } + + private func sortIndexedItems( + _ items: inout [IndexedClipboardItem], + ordering: VisibleItemsSortOrdering + ) { + guard items.count > 1 else { return } + items.sort { lhs, rhs in + switch ordering { + case .recency: + return recencyPrecedes(lhs, rhs) + case .useCount: + return useCountPrecedes(lhs, rhs) + case .lastUsed: + return lastUsedPrecedes(lhs, rhs) + case .original: + return lhs.offset < rhs.offset + } + } + } + + private func recencyPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool { + if lhs.item.lastUsedAt == rhs.item.lastUsedAt { + if lhs.item.createdAt == rhs.item.createdAt { + return lhs.offset < rhs.offset + } + return lhs.item.createdAt > rhs.item.createdAt + } + return lhs.item.lastUsedAt > rhs.item.lastUsedAt + } + + private func useCountPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool { + if lhs.item.useCount == rhs.item.useCount { + return lastUsedPrecedes(lhs, rhs) + } + return lhs.item.useCount > rhs.item.useCount + } + + private func lastUsedPrecedes(_ lhs: IndexedClipboardItem, _ rhs: IndexedClipboardItem) -> Bool { + if lhs.item.lastUsedAt == rhs.item.lastUsedAt { + return lhs.offset < rhs.offset + } + return lhs.item.lastUsedAt > rhs.item.lastUsedAt + } + private func searchableText(for item: ClipboardItem) -> String { - var base = item.searchableText + var base = Self.normalizedSearchValue(item.searchableText) if settings.includeImageTextInSearch, let ocrText = item.ocrText { - base += " \(ocrText.lowercased())" + base += " \(Self.normalizedSearchValue(ocrText))" } return base } @@ -919,21 +2308,46 @@ final class ClipboardPanelViewModel { } if !query.appTokenGroups.isEmpty { - let source = [item.sourceApp, item.sourceAppBundleId] - .compactMap { $0?.lowercased() } - .joined(separator: " ") - guard !source.isEmpty, + let sourceValues = [item.sourceApp, item.sourceAppBundleId] + .compactMap { $0 } + .map(normalizedStructuredValue) + .filter { !$0.isEmpty } + let sourceText = sourceValues.joined(separator: " ") + guard !sourceValues.isEmpty, query.appTokenGroups.contains(where: { group in - group.allSatisfy { source.contains($0) } + if let exactValue = group.exactValue { + return sourceValues.contains(exactValue) + } + return group.tokens.allSatisfy { sourceText.contains($0) } }) else { return false } } + if !query.deviceTokenGroups.isEmpty { + let device = normalizedStructuredValue(item.effectiveSourceDeviceName) + guard query.deviceTokenGroups.contains(where: { group in + if let exactValue = group.exactValue { + return device == exactValue + } + return group.tokens.allSatisfy { device.contains($0) } + }) else { + return false + } + } + if !query.collectionTokenGroups.isEmpty { - guard let collection = item.collectionName?.lowercased(), + guard let collectionName = item.collectionName, + !collectionName.clipboardTrimmed.isEmpty else { + return false + } + let collection = normalizedStructuredValue(collectionName) + guard !collection.isEmpty, query.collectionTokenGroups.contains(where: { group in - group.allSatisfy { collection.contains($0) } + if let exactValue = group.exactValue { + return collection == exactValue + } + return group.tokens.allSatisfy { collection.contains($0) } }) else { return false } @@ -963,14 +2377,14 @@ final class ClipboardPanelViewModel { for part in searchParts(from: query) { guard !part.isEmpty else { continue } guard let delimiter = part.firstIndex(of: ":") else { - parsed.textTokens.append(contentsOf: searchTokens(from: part.lowercased())) + parsed.textTokens.append(contentsOf: searchTokens(from: part)) continue } - let key = String(part[..() for segment in structuredValueSegments(from: value) { - guard let kinds = itemKinds(matching: segment), !kinds.isEmpty else { return false } + guard let kinds = itemKinds(matching: segment.value), !kinds.isEmpty else { return false } matchedKinds.formUnion(kinds) } guard !matchedKinds.isEmpty else { return false } @@ -1028,6 +2447,7 @@ final class ClipboardPanelViewModel { var parts: [String] = [] var current = "" var quotedBy: Character? + var isEscaping = false func flushCurrent() { let part = current.clipboardTrimmed @@ -1038,13 +2458,27 @@ final class ClipboardPanelViewModel { } for character in query { + if isEscaping { + current.append("\\") + current.append(character) + isEscaping = false + continue + } + + if character == "\\" && quotedBy != nil { + isEscaping = true + continue + } + if character == "\"" || character == "'" { if quotedBy == character { + current.append(character) quotedBy = nil continue } if quotedBy == nil { quotedBy = character + current.append(character) continue } } @@ -1056,21 +2490,115 @@ final class ClipboardPanelViewModel { } } + if isEscaping { + current.append("\\") + } flushCurrent() return parts } - private func structuredValueSegments(from value: String) -> [String] { - value - .split(separator: ",") - .map { String($0).clipboardTrimmed.lowercased() } - .filter { !$0.isEmpty } + private func structuredValueSegments(from value: String) -> [StructuredFilterValue] { + splitStructuredValues(from: value) + .compactMap { segment in + let trimmed = segment.clipboardTrimmed + guard !trimmed.isEmpty else { return nil } + if let unquoted = unquotedStructuredValue(trimmed) { + let normalized = normalizedStructuredValue(unquoted) + return normalized.isEmpty ? nil : StructuredFilterValue(value: normalized, isExact: true) + } + let normalized = normalizedStructuredValue(trimmed) + return normalized.isEmpty ? nil : StructuredFilterValue(value: normalized, isExact: false) + } } - private func structuredTokenGroups(from value: String) -> [[String]] { + private func structuredTokenGroups(from value: String) -> [StructuredFilterMatcher] { structuredValueSegments(from: value) - .map { searchTokens(from: $0) } - .filter { !$0.isEmpty } + .compactMap { segment in + let tokens = searchTokens(from: segment.value) + guard !tokens.isEmpty else { return nil } + return StructuredFilterMatcher(tokens: tokens, exactValue: segment.isExact ? segment.value : nil) + } + } + + private func splitStructuredValues(from value: String) -> [String] { + var segments: [String] = [] + var current = "" + var quotedBy: Character? + var isEscaping = false + + func flushCurrent() { + segments.append(current) + current = "" + } + + for character in value { + if isEscaping { + current.append("\\") + current.append(character) + isEscaping = false + continue + } + + if character == "\\" && quotedBy != nil { + isEscaping = true + continue + } + + if character == "\"" || character == "'" { + if quotedBy == character { + current.append(character) + quotedBy = nil + continue + } + if quotedBy == nil { + quotedBy = character + current.append(character) + continue + } + } + + if character == "," && quotedBy == nil { + flushCurrent() + } else { + current.append(character) + } + } + + if isEscaping { + current.append("\\") + } + flushCurrent() + return segments + } + + private func unquotedStructuredValue(_ value: String) -> String? { + guard let first = value.first, + (first == "\"" || first == "'"), + value.last == first, + value.count >= 2 else { + return nil + } + let inner = String(value.dropFirst().dropLast()) + var unescaped = "" + var isEscaping = false + for character in inner { + if isEscaping { + unescaped.append(character) + isEscaping = false + } else if character == "\\" { + isEscaping = true + } else { + unescaped.append(character) + } + } + if isEscaping { + unescaped.append("\\") + } + return unescaped + } + + private func normalizedStructuredValue(_ value: String) -> String { + Self.normalizedSearchValue(value) } private func itemKinds(matching value: String) -> Set? { @@ -1105,7 +2633,7 @@ final class ClipboardPanelViewModel { } private func booleanValue(from value: String) -> Bool? { - switch value { + switch Self.normalizedSearchValue(value) { case "1", "true", "yes", "y", "on": return true case "0", "false", "no", "n", "off": @@ -1133,9 +2661,15 @@ final class ClipboardPanelViewModel { private func assign(item: ClipboardItem, to collectionName: String?) { let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName) if let normalizedName { + isApplyingLocalCollectionMutation = true settings.ensureCollection(named: normalizedName) + isApplyingLocalCollectionMutation = false } + isDeferringLocalStoreRecompute = true store.setCollection(item.id, name: normalizedName) + isDeferringLocalStoreRecompute = false + recomputeVisibleItems() + onCollectionsChanged?() if let normalizedName { statusMessage = "Added to \(normalizedName)" } else { @@ -1163,13 +2697,19 @@ final class ClipboardPanelViewModel { } private func searchTokens(from query: String) -> [String] { - query + Self.normalizedSearchValue(query) .split { character in character.isWhitespace || character.isPunctuation } .map(String.init) } + private static func normalizedSearchValue(_ value: String) -> String { + value.clipboardTrimmed + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale) + .lowercased() + } + private func notifyMain(_ block: @escaping () -> Void) { if Thread.isMainThread { block() @@ -1180,10 +2720,10 @@ final class ClipboardPanelViewModel { } private extension Calendar { - static let searchCalendar: Calendar = { + static var searchCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "en_US_POSIX") - calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + calendar.timeZone = .autoupdatingCurrent return calendar - }() + } } diff --git a/sources/clipbored/views/LinkPreviewWindowController.swift b/sources/clipbored/views/LinkPreviewWindowController.swift new file mode 100644 index 0000000..f9870e5 --- /dev/null +++ b/sources/clipbored/views/LinkPreviewWindowController.swift @@ -0,0 +1,404 @@ +import AppKit +import WebKit + +final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegate { + private enum Metrics { + static let minimumWidth: CGFloat = 760 + static let minimumHeight: CGFloat = 420 + static let preferredHeight: CGFloat = 560 + static let margin: CGFloat = 24 + static let panelGap: CGFloat = 14 + static let toolbarHeight: CGFloat = 52 + static let toolbarLeadingInset: CGFloat = 86 + } + + private let webView: WKWebView + private let titleLabel = NSTextField(labelWithString: "") + private let addressLabel = NSTextField(labelWithString: "") + private let statusLabel = NSTextField(labelWithString: "") + private let progressIndicator = NSProgressIndicator() + private let backButton = NSButton() + private let forwardButton = NSButton() + private let reloadButton = NSButton() + private let openExternalButton = NSButton() + private let openURL: (URL) -> Void + private var progressObservation: NSKeyValueObservation? + private var titleObservation: NSKeyValueObservation? + private var canGoBackObservation: NSKeyValueObservation? + private var canGoForwardObservation: NSKeyValueObservation? + private var currentRequest: LinkPreviewRequest? + private var currentPageURL: URL? + private var acceptsObservedPageTitles = false + + init(openURL: @escaping (URL) -> Void = { _ = NSWorkspace.shared.open($0) }) { + self.openURL = openURL + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + + webView = WKWebView(frame: .zero, configuration: configuration) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: Metrics.minimumWidth, height: Metrics.preferredHeight), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = "Link Preview" + window.minSize = NSSize(width: 560, height: 420) + window.isReleasedWhenClosed = false + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + + super.init(window: window) + configureContent(in: window) + configureObservations() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show(_ request: LinkPreviewRequest, relativeTo parent: NSWindow?) { + prepareForPreview(request) + if let parent, let window { + window.setFrame(Self.previewFrame(relativeTo: parent), display: false) + } + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + webView.load(URLRequest(url: request.url)) + } + + private func prepareForPreview(_ request: LinkPreviewRequest) { + currentRequest = request + currentPageURL = request.url + acceptsObservedPageTitles = false + setTitleText(Self.displayTitle(for: request)) + setAddress(request.url) + setStatus("Loading") + } + + private static func displayTitle(for request: LinkPreviewRequest) -> String { + request.title.clipboardTrimmed.isEmpty ? request.url.host ?? "Link Preview" : request.title + } + + static func previewFrame(relativeTo parent: NSWindow) -> NSRect { + let visibleFrame = parent.screen?.visibleFrame ?? parent.frame + return previewFrame(parentFrame: parent.frame, visibleFrame: visibleFrame) + } + + static func previewFrame(parentFrame: NSRect, visibleFrame: NSRect) -> NSRect { + let usableWidth = max(1, visibleFrame.width - (Metrics.margin * 2)) + let width = min(max(Metrics.minimumWidth, floor(parentFrame.width * 0.72)), usableWidth) + let usableHeight = max(1, visibleFrame.height - (Metrics.margin * 2)) + let abovePanelY = parentFrame.maxY + Metrics.panelGap + let availableAbovePanel = visibleFrame.maxY - abovePanelY - Metrics.margin + let preferredHeight = min(Metrics.preferredHeight, usableHeight) + let height = availableAbovePanel >= Metrics.minimumHeight + ? min(preferredHeight, availableAbovePanel) + : preferredHeight + let centeredX = parentFrame.midX - (width / 2) + let x = min(max(visibleFrame.minX + Metrics.margin, centeredX), visibleFrame.maxX - width - Metrics.margin) + let preferredY = availableAbovePanel >= Metrics.minimumHeight ? abovePanelY : visibleFrame.midY - (height / 2) + let y = min(max(visibleFrame.minY + Metrics.margin, preferredY), visibleFrame.maxY - height - Metrics.margin) + return NSRect(x: floor(x), y: floor(y), width: floor(width), height: floor(height)) + } + + private func configureContent(in window: NSWindow) { + webView.navigationDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.translatesAutoresizingMaskIntoConstraints = false + + let content = NSView() + content.wantsLayer = true + content.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + content.translatesAutoresizingMaskIntoConstraints = false + window.contentView = content + + let toolbar = NSVisualEffectView() + toolbar.material = .windowBackground + toolbar.blendingMode = .withinWindow + toolbar.state = .active + toolbar.translatesAutoresizingMaskIntoConstraints = false + + let titleColumn = NSStackView(views: [titleLabel, addressLabel]) + titleColumn.orientation = .vertical + titleColumn.alignment = .leading + titleColumn.spacing = 2 + titleColumn.translatesAutoresizingMaskIntoConstraints = false + + titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 1 + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addressLabel.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + addressLabel.textColor = .secondaryLabelColor + addressLabel.lineBreakMode = .byTruncatingMiddle + addressLabel.maximumNumberOfLines = 1 + addressLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let controls = NSStackView(views: [ + configuredButton(backButton, symbol: "chevron.left", toolTip: "Back", action: #selector(goBack)), + configuredButton(forwardButton, symbol: "chevron.right", toolTip: "Forward", action: #selector(goForward)), + configuredButton(reloadButton, symbol: "arrow.clockwise", toolTip: "Reload", action: #selector(reload)), + configuredButton(openExternalButton, symbol: "arrow.up.right.square", toolTip: "Open in Browser", action: #selector(openInBrowser)) + ]) + controls.orientation = .horizontal + controls.alignment = .centerY + controls.spacing = 4 + controls.translatesAutoresizingMaskIntoConstraints = false + + progressIndicator.isIndeterminate = false + progressIndicator.minValue = 0 + progressIndicator.maxValue = 1 + progressIndicator.controlSize = .small + progressIndicator.style = .bar + progressIndicator.translatesAutoresizingMaskIntoConstraints = false + + statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + statusLabel.textColor = .secondaryLabelColor + statusLabel.lineBreakMode = .byTruncatingTail + statusLabel.maximumNumberOfLines = 1 + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + content.addSubview(toolbar) + toolbar.addSubview(controls) + toolbar.addSubview(titleColumn) + toolbar.addSubview(statusLabel) + toolbar.addSubview(progressIndicator) + content.addSubview(webView) + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: content.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: content.trailingAnchor), + toolbar.topAnchor.constraint(equalTo: content.topAnchor), + toolbar.heightAnchor.constraint(equalToConstant: Metrics.toolbarHeight), + + controls.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: Metrics.toolbarLeadingInset), + controls.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + + titleColumn.leadingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 14), + titleColumn.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + titleColumn.trailingAnchor.constraint(lessThanOrEqualTo: statusLabel.leadingAnchor, constant: -16), + + statusLabel.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -16), + statusLabel.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor), + statusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 180), + + progressIndicator.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor), + progressIndicator.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor), + progressIndicator.bottomAnchor.constraint(equalTo: toolbar.bottomAnchor), + progressIndicator.heightAnchor.constraint(equalToConstant: 2), + + webView.leadingAnchor.constraint(equalTo: content.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: content.trailingAnchor), + webView.topAnchor.constraint(equalTo: toolbar.bottomAnchor), + webView.bottomAnchor.constraint(equalTo: content.bottomAnchor) + ]) + + updateNavigationButtons() + } + + private func configuredButton( + _ button: NSButton, + symbol: String, + toolTip: String, + action: Selector + ) -> NSButton { + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolTip) + image?.isTemplate = true + button.image = image + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = 6 + button.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.06).cgColor + button.contentTintColor = .labelColor + button.toolTip = toolTip + button.setAccessibilityLabel(toolTip) + button.target = self + button.action = action + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 30).isActive = true + button.heightAnchor.constraint(equalToConstant: 30).isActive = true + return button + } + + private func configureObservations() { + progressObservation = webView.observe(\.estimatedProgress, options: [.initial, .new]) { [weak self] webView, _ in + self?.progressIndicator.doubleValue = webView.estimatedProgress + self?.progressIndicator.isHidden = webView.estimatedProgress >= 1 + } + titleObservation = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in + self?.applyObservedPageTitle(webView.title) + } + canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] _, _ in + self?.updateNavigationButtons() + } + canGoForwardObservation = webView.observe(\.canGoForward, options: [.initial, .new]) { [weak self] _, _ in + self?.updateNavigationButtons() + } + } + + private func updateNavigationButtons() { + backButton.isEnabled = webView.canGoBack + forwardButton.isEnabled = webView.canGoForward + } + + private func applyObservedPageTitle(_ title: String?) { + guard acceptsObservedPageTitles, + let title = title?.clipboardTrimmed, + !title.isEmpty else { + return + } + setTitleText(title) + } + + private func setTitleText(_ text: String) { + titleLabel.stringValue = text + titleLabel.toolTip = text + window?.title = text + } + + private func setAddress(_ url: URL) { + currentPageURL = url + let text = url.absoluteString + addressLabel.stringValue = text + addressLabel.toolTip = text + } + + private func setStatus(_ text: String) { + statusLabel.stringValue = text + statusLabel.toolTip = text.isEmpty ? nil : text + } + + @objc private func goBack() { + guard webView.canGoBack else { return } + webView.goBack() + } + + @objc private func goForward() { + guard webView.canGoForward else { return } + webView.goForward() + } + + @objc private func reload() { + webView.reload() + } + + @objc private func openInBrowser() { + guard let url = currentPageURL ?? currentRequest?.url else { return } + openURL(url) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + acceptsObservedPageTitles = true + setStatus("Loading") + if let url = webView.url { + setAddress(url) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + setStatus("") + if let url = webView.url { + setAddress(url) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleNavigationFailure(error) + } + + private func handleNavigationFailure(_ error: Error) { + guard !Self.isNavigationCancellation(error) else { return } + setStatus("Could not load") + } + + private static func isNavigationCancellation(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + return true + } + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + return isNavigationCancellation(underlying) + } + return false + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + + let scheme = url.scheme?.lowercased() + guard scheme == "http" || scheme == "https" else { + openURL(url) + decisionHandler(.cancel) + return + } + + 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 new file mode 100644 index 0000000..a3052f4 --- /dev/null +++ b/sources/clipbored/views/OnboardingWindowController.swift @@ -0,0 +1,367 @@ +import AppKit + +final class OnboardingWindowController: NSObject, NSWindowDelegate { + enum ShortcutChoice: String { + case pasteStyle + case clipBoredDefault + case current + } + + struct PresentationChoice: Equatable { + let showMenuBarIcon: Bool + let showDockIcon: Bool + } + + static let pasteStyleOpenShortcut = ShortcutBinding( + key: "v", + modifierFlags: NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue + ) + + static func normalizedPresentation(showMenuBarIcon: Bool, showDockIcon: Bool) -> PresentationChoice { + if showMenuBarIcon || showDockIcon { + return PresentationChoice(showMenuBarIcon: showMenuBarIcon, showDockIcon: showDockIcon) + } + return PresentationChoice(showMenuBarIcon: true, showDockIcon: false) + } + + static func initialShortcutChoice(for binding: ShortcutBinding, onboardingCompleted: Bool) -> ShortcutChoice { + if binding == pasteStyleOpenShortcut { + return .pasteStyle + } + if binding == AppConfiguration.defaultOpenShortcut { + return onboardingCompleted ? .clipBoredDefault : .pasteStyle + } + return .current + } + + static func shortcutBinding(for choice: ShortcutChoice, current: ShortcutBinding) -> ShortcutBinding { + switch choice { + case .pasteStyle: + return pasteStyleOpenShortcut + case .clipBoredDefault: + return AppConfiguration.defaultOpenShortcut + case .current: + return current + } + } + + private let settings: SettingsModel + private let onOpenAccessibility: () -> Void + private let onFinish: () -> Void + private var window: NSWindow? + private var didComplete = false + + private let shortcutPopup = NSPopUpButton() + private let historyRetentionPopup = NSPopUpButton() + private let showMenuBarIconButton = NSButton() + private let showDockIconButton = NSButton() + private let launchAtLoginButton = NSButton() + private let iCloudSyncButton = NSButton() + private let permissionStatusLabel = NSTextField(labelWithString: "") + + init( + settings: SettingsModel, + onOpenAccessibility: @escaping () -> Void, + onFinish: @escaping () -> Void + ) { + self.settings = settings + self.onOpenAccessibility = onOpenAccessibility + self.onFinish = onFinish + super.init() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 540), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Set Up ClipBored" + window.contentView = makeContentView() + window.delegate = self + window.isReleasedWhenClosed = false + window.center() + self.window = window + refreshFromSettings() + } + + func show() { + guard let window else { return } + refreshFromSettings() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func refreshPermissionStatus() { + let isTrusted = AccessibilityPermissionService.isTrusted + permissionStatusLabel.stringValue = isTrusted ? "Granted" : "Not granted; paste actions will copy instead." + permissionStatusLabel.textColor = isTrusted ? .systemGreen : .systemOrange + } + + func windowWillClose(_ notification: Notification) { + guard !didComplete else { return } + completeSetup(applySelections: false, closeWindow: false) + } + + private func makeContentView() -> NSView { + configureShortcutPopup() + configureHistoryRetentionPopup() + configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(entryPointChanged)) + configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(entryPointChanged)) + configureCheckbox(launchAtLoginButton, title: "Launch at login", action: nil) + configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud when available", action: nil) + configureStatusLabel(permissionStatusLabel) + + let content = NSView() + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 18 + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + + let titleLabel = NSTextField(labelWithString: "Set Up ClipBored") + titleLabel.font = .boldSystemFont(ofSize: 22) + let subtitleLabel = caption("Choose the shortcut, history window, and system entry points for this Mac.") + let header = NSStackView(views: [titleLabel, subtitleLabel]) + header.orientation = .vertical + header.alignment = .leading + header.spacing = 4 + + stack.addArrangedSubview(header) + stack.addArrangedSubview(section("Open ClipBored", [ + labeledRow("Shortcut", shortcutPopup) + ])) + stack.addArrangedSubview(section("History", [ + labeledRow("Keep History", historyRetentionPopup) + ])) + stack.addArrangedSubview(section("System", [ + showMenuBarIconButton, + showDockIconButton, + launchAtLoginButton, + iCloudSyncButton + ])) + stack.addArrangedSubview(section("Automatic Paste", [ + caption("Accessibility is only needed when ClipBored pastes directly into the previous app."), + labeledRow("Accessibility", permissionStatusLabel), + button("Open Accessibility Settings", #selector(openAccessibilitySettings)) + ])) + stack.addArrangedSubview(NSView()) + stack.addArrangedSubview(buttonRow()) + + if let spacer = stack.arrangedSubviews.dropLast().last { + spacer.setContentHuggingPriority(.defaultLow, for: .vertical) + } + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), + stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), + shortcutPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 220), + historyRetentionPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 160) + ]) + + return content + } + + private func configureShortcutPopup() { + shortcutPopup.removeAllItems() + addShortcutItem("Shift-Command-V", .pasteStyle) + addShortcutItem("Command-Option-V", .clipBoredDefault) + if settings.openShortcut != Self.pasteStyleOpenShortcut, + settings.openShortcut != AppConfiguration.defaultOpenShortcut { + addShortcutItem("Keep Current (\(settings.openShortcut.displayText))", .current) + } + shortcutPopup.setAccessibilityLabel("Open ClipBored shortcut") + } + + private func configureHistoryRetentionPopup() { + historyRetentionPopup.removeAllItems() + for retention in HistoryRetention.allCases { + historyRetentionPopup.addItem(withTitle: retention.title) + historyRetentionPopup.lastItem?.representedObject = retention.rawValue + } + historyRetentionPopup.setAccessibilityLabel("Keep History") + } + + private func addShortcutItem(_ title: String, _ choice: ShortcutChoice) { + shortcutPopup.addItem(withTitle: title) + shortcutPopup.lastItem?.representedObject = choice.rawValue + } + + private func refreshFromSettings() { + selectShortcut(Self.initialShortcutChoice(for: settings.openShortcut, onboardingCompleted: settings.onboardingCompleted)) + select(historyRetentionPopup, rawValue: settings.historyRetention.rawValue) + let presentation = Self.normalizedPresentation( + showMenuBarIcon: settings.showMenuBarIcon, + showDockIcon: settings.showDockIcon + ) + showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off + showDockIconButton.state = presentation.showDockIcon ? .on : .off + launchAtLoginButton.state = settings.launchAtLogin ? .on : .off + iCloudSyncButton.state = settings.iCloudSyncEnabled ? .on : .off + refreshPermissionStatus() + } + + private func section(_ title: String, _ views: [NSView]) -> NSView { + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + let stack = NSStackView(views: [titleLabel] + views) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 520).isActive = true + return stack + } + + private func row(_ views: [NSView]) -> NSView { + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 10 + return stack + } + + private func labeledRow(_ title: String, _ control: NSView) -> NSView { + let label = NSTextField(labelWithString: title) + label.widthAnchor.constraint(equalToConstant: 120).isActive = true + return row([label, control]) + } + + private func caption(_ text: String) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: text) + label.textColor = .secondaryLabelColor + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.widthAnchor.constraint(lessThanOrEqualToConstant: 520).isActive = true + return label + } + + private func button(_ title: String, _ action: Selector) -> NSButton { + let control = NSButton(title: title, target: self, action: action) + control.bezelStyle = .rounded + control.setAccessibilityLabel(title) + return control + } + + private func buttonRow() -> NSView { + let skipButton = NSButton(title: "Skip", target: self, action: #selector(skipSetup)) + skipButton.bezelStyle = .rounded + skipButton.setAccessibilityLabel("Skip setup") + + let finishButton = NSButton(title: "Finish Setup", target: self, action: #selector(finishSetup)) + finishButton.bezelStyle = .rounded + finishButton.keyEquivalent = "\r" + finishButton.setAccessibilityLabel("Finish setup") + + let spacer = NSView() + let stack = NSStackView(views: [spacer, skipButton, finishButton]) + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 10 + stack.widthAnchor.constraint(equalToConstant: 520).isActive = true + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + return stack + } + + private func configureCheckbox(_ control: NSButton, title: String, action: Selector?) { + control.setButtonType(.switch) + control.title = title + control.target = action == nil ? nil : self + control.action = action + control.setAccessibilityLabel(title) + } + + private func configureStatusLabel(_ label: NSTextField) { + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.lineBreakMode = .byTruncatingTail + } + + private func selectShortcut(_ choice: ShortcutChoice) { + for item in shortcutPopup.itemArray where item.representedObject as? String == choice.rawValue { + shortcutPopup.select(item) + return + } + } + + private func select(_ popup: NSPopUpButton, rawValue: Int) { + for item in popup.itemArray where item.representedObject as? Int == rawValue { + popup.select(item) + return + } + } + + private func selectedShortcutChoice() -> ShortcutChoice { + guard let rawValue = shortcutPopup.selectedItem?.representedObject as? String, + let choice = ShortcutChoice(rawValue: rawValue) + else { + return .pasteStyle + } + return choice + } + + @objc private func entryPointChanged() { + let presentation = Self.normalizedPresentation( + showMenuBarIcon: showMenuBarIconButton.state == .on, + showDockIcon: showDockIconButton.state == .on + ) + showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off + showDockIconButton.state = presentation.showDockIcon ? .on : .off + } + + @objc private func openAccessibilitySettings() { + onOpenAccessibility() + refreshPermissionStatus() + } + + @objc private func finishSetup() { + completeSetup(applySelections: true, closeWindow: true) + } + + @objc private func skipSetup() { + completeSetup(applySelections: false, closeWindow: true) + } + + private func completeSetup(applySelections: Bool, closeWindow: Bool) { + guard !didComplete else { return } + didComplete = true + + if applySelections { + applySelectedSettings() + } + settings.markAccessibilityNoticeShown() + settings.markOnboardingCompleted() + + if closeWindow { + window?.delegate = nil + window?.close() + } + onFinish() + } + + private func applySelectedSettings() { + settings.openShortcut = Self.shortcutBinding(for: selectedShortcutChoice(), current: settings.openShortcut) + if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int, + let retention = HistoryRetention(rawValue: rawValue) { + settings.historyRetention = retention + } + + let presentation = Self.normalizedPresentation( + showMenuBarIcon: showMenuBarIconButton.state == .on, + showDockIcon: showDockIconButton.state == .on + ) + settings.showMenuBarIcon = presentation.showMenuBarIcon + settings.showDockIcon = presentation.showDockIcon + settings.launchAtLogin = launchAtLoginButton.state == .on + 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 14fc258..fb987a7 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -1,23 +1,44 @@ import AppKit -final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewDelegate { +private final class TopAlignedSettingsDocumentView: NSView { + override var isFlipped: Bool { + true + } +} + +final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDelegate, NSTextViewDelegate { + private enum Metrics { + static let windowSize = NSSize(width: 720, height: 620) + static let minimumWindowSize = NSSize(width: 620, height: 560) + static let settingsContentMinimumWidth: CGFloat = 520 + static let settingsLabelWidth: CGFloat = 150 + } + private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled." + private static let allowedContentTypesUpdatedMessage = "Allowed content types updated." + private let settings: SettingsModel private let store: ClipboardStore private let cacheService: ClipboardCacheService + private let cloudSyncService: ClipboardCloudSyncServicing private var window: NSWindow? + private var cachedCloudSyncStatus: ClipboardCloudSyncStatus? + private let tabView = NSTabView() private let historyLabel = NSTextField(labelWithString: "") private let historyStepper = NSStepper() + private let historyRetentionPopup = NSPopUpButton() private let pruneDuplicatesButton = NSButton() private let keepFirstImageButton = NSButton() private let defaultSortPopup = NSPopUpButton() private let launchAtLoginButton = NSButton() private let showMenuBarIconButton = NSButton() private let showDockIconButton = NSButton() + private let panelSidePopup = NSPopUpButton() private let launchStatusLabel = NSTextField(labelWithString: "") private var openShortcutControls: ShortcutControlSet? private var settingsShortcutControls: ShortcutControlSet? + private var editingShortcutFieldTag: Int? private let shortcutStatusLabel = NSTextField(labelWithString: "") private let pauseCaptureButton = NSButton() @@ -26,57 +47,83 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD private let includeImageTextButton = NSButton() private var allowedKindButtons: [(ClipboardItemKind, NSButton)] = [] private let ignoredAppsTextView = NSTextView() + private var ignoredAppsEditorIsEditing = false + private var ignoredAppsEditorHasDraft = false private let clearHistoryOnQuitButton = NSButton() + private let hideFromScreenCaptureButton = NSButton() private let accessibilityStatusLabel = NSTextField(labelWithString: "") private let pasteStatusLabel = NSTextField(labelWithString: "") private let pollProfilePopup = NSPopUpButton() private let cacheSlider = NSSlider() private let cacheLabel = NSTextField(labelWithString: "") + private let dataStatusLabel = NSTextField(labelWithString: "") + private let iCloudSyncButton = NSButton() + private let iCloudSyncNowButton = NSButton() + private let iCloudRestoreButton = NSButton() + private let iCloudRevealButton = NSButton() + private let cloudSyncStatusLabel = NSTextField(labelWithString: "") - init(settings: SettingsModel, store: ClipboardStore, cacheService: ClipboardCacheService) { + #if DEBUG + private var debugFullRefreshCountValue = 0 + private var debugIgnoredAppsRefreshCountValue = 0 + private var debugDestructiveActionConfirmationOverride: Bool? + #endif + + init( + settings: SettingsModel, + store: ClipboardStore, + cacheService: ClipboardCacheService, + cloudSyncService: ClipboardCloudSyncServicing = ClipboardCloudSyncService() + ) { self.settings = settings self.store = store self.cacheService = cacheService + self.cloudSyncService = cloudSyncService super.init() - let windowRect = NSRect(x: 0, y: 0, width: 620, height: 560) + let windowRect = NSRect(origin: .zero, size: Metrics.windowSize) let window = NSWindow( contentRect: windowRect, - styleMask: [.titled, .closable, .miniaturizable], + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) window.title = "ClipBored Settings" window.contentView = makeContentView() window.isReleasedWhenClosed = false + window.minSize = Metrics.minimumWindowSize + window.delegate = self window.center() self.window = window - settings.observe { [weak self] _ in + settings.observe { [weak self] change in DispatchQueue.main.async { - self?.refreshFromSettings() + self?.refreshFromSettings(for: change) } } - refreshFromSettings() + refreshFromSettings(refreshCloudSyncStatus: true) } func show() { guard let window else { return } - refreshFromSettings() + refreshFromSettings(refreshCloudSyncStatus: true) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } + func windowWillClose(_ notification: Notification) { + commitPendingEditorDrafts() + } + private func makeContentView() -> NSView { - let tabView = NSTabView() tabView.translatesAutoresizingMaskIntoConstraints = false tabView.addTabViewItem(tab("General", generalSettingsView())) tabView.addTabViewItem(tab("Shortcuts", shortcutSettingsView())) tabView.addTabViewItem(tab("Capture", captureSettingsView())) tabView.addTabViewItem(tab("Privacy", privacySettingsView())) tabView.addTabViewItem(tab("Performance", performanceSettingsView())) - tabView.addTabViewItem(tab("Data", dataSettingsView())) + tabView.addTabViewItem(tab("Data ", dataSettingsView())) let container = NSView() container.addSubview(tabView) @@ -96,14 +143,32 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD 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 + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true scrollView.drawsBackground = false - scrollView.documentView = content + + let documentView = TopAlignedSettingsDocumentView() + documentView.translatesAutoresizingMaskIntoConstraints = false content.translatesAutoresizingMaskIntoConstraints = false + documentView.addSubview(content) + scrollView.documentView = documentView NSLayoutConstraint.activate([ - content.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor) + documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), + documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.bottomAnchor), + documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + content.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + content.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + content.topAnchor.constraint(equalTo: documentView.topAnchor), + content.bottomAnchor.constraint(equalTo: documentView.bottomAnchor) ]) return scrollView } @@ -115,6 +180,11 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD historyStepper.target = self historyStepper.action = #selector(historyLengthChanged) historyStepper.setAccessibilityLabel("History length") + configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged)) + 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)) @@ -126,10 +196,16 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD 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)) + panelSidePopup.setAccessibilityLabel("Shelf side") + for side in ClipboardPanelSide.allCases { + addPopupItem(side.title, side.rawValue, to: panelSidePopup) + } configureStatusLabel(launchStatusLabel) return page([ section("History", [ + labeledRow("Keep History", historyRetentionPopup), row([historyLabel, historyStepper]), pruneDuplicatesButton, keepFirstImageButton @@ -137,6 +213,9 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD section("Sort", [ labeledRow("Default sort", defaultSortPopup) ]), + section("Panel", [ + labeledRow("Shelf side", panelSidePopup) + ]), section("Lifecycle", [ launchAtLoginButton, showMenuBarIconButton, @@ -180,9 +259,19 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD ignoredAppsTextView.delegate = self ignoredAppsTextView.font = .systemFont(ofSize: NSFont.systemFontSize) + ignoredAppsTextView.isHorizontallyResizable = false + ignoredAppsTextView.isVerticallyResizable = true + ignoredAppsTextView.autoresizingMask = [.width] + ignoredAppsTextView.textContainer?.widthTracksTextView = true + ignoredAppsTextView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) ignoredAppsTextView.setAccessibilityLabel("Ignored source apps") let ignoredScroll = NSScrollView() ignoredScroll.hasVerticalScroller = true + ignoredScroll.hasHorizontalScroller = false + ignoredScroll.autohidesScrollers = true ignoredScroll.borderType = .bezelBorder ignoredScroll.documentView = ignoredAppsTextView ignoredScroll.heightAnchor.constraint(equalToConstant: 96).isActive = true @@ -202,9 +291,11 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } private func privacySettingsView() -> NSView { - let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, video clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.") - let permissionHelpLabel = caption("Clipboard history capture works without this permission. Grant Accessibility to paste selected items into the previous app.") + 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)) configureStatusLabel(accessibilityStatusLabel) let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess)) let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus)) @@ -215,6 +306,10 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD storageLabel, clearHistoryOnQuitButton ]), + section("Screen privacy", [ + screenPrivacyLabel, + hideFromScreenCaptureButton + ]), section("Paste permission", [ permissionHelpLabel, row([NSTextField(labelWithString: "Accessibility"), accessibilityStatusLabel]), @@ -232,8 +327,8 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD for profile in AppConfiguration.PollProfile.allCases { addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup) } - cacheSlider.minValue = 4 - cacheSlider.maxValue = 512 + cacheSlider.minValue = Double(AppConfiguration.minCacheMaxBytes) / 1024 / 1024 + cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024 cacheSlider.numberOfTickMarks = 9 cacheSlider.allowsTickMarkValuesOnly = true cacheSlider.target = self @@ -253,11 +348,37 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } private func dataSettingsView() -> NSView { - page([ + 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)) + 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([ + section("iCloud Sync", [ + cloudLabel, + iCloudSyncButton, + row([ + iCloudSyncNowButton, + iCloudRestoreButton, + iCloudRevealButton + ]), + cloudSyncStatusLabel + ]), + section("Archive", [ + archiveLabel, + row([ + button("Export Archive...", #selector(exportClipboardArchive)), + button("Import Archive...", #selector(importClipboardArchive)) + ]) + ]), section("Data", [ button("Open History Folder", #selector(openHistoryFolder)), button("Clear Clipboard History", #selector(clearClipboardHistory)), - button("Clear Thumbnail Cache", #selector(clearThumbnailCache)) + button("Clear Thumbnail Cache", #selector(clearThumbnailCache)), + dataStatusLabel ]) ]) } @@ -278,7 +399,9 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD stack.orientation = .vertical stack.alignment = .leading stack.spacing = 8 - stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 520).isActive = true + let minimumWidth = stack.widthAnchor.constraint(greaterThanOrEqualToConstant: Metrics.settingsContentMinimumWidth) + minimumWidth.priority = .defaultLow + minimumWidth.isActive = true return stack } @@ -286,13 +409,17 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD let stack = NSStackView(views: views) stack.orientation = .horizontal stack.alignment = .centerY + stack.distribution = .fill stack.spacing = 10 + stack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return stack } private func labeledRow(_ title: String, _ control: NSView) -> NSView { let label = NSTextField(labelWithString: title) - label.widthAnchor.constraint(equalToConstant: 150).isActive = true + label.widthAnchor.constraint(equalToConstant: Metrics.settingsLabelWidth).isActive = true + label.setContentCompressionResistancePriority(.required, for: .horizontal) + control.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return row([label, control]) } @@ -305,10 +432,17 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } private func button(_ title: String, _ action: Selector) -> NSButton { - let control = NSButton(title: title, target: self, action: action) + let control = NSButton() + configureButton(control, title: title, action: action) + return control + } + + private func configureButton(_ control: NSButton, title: String, action: Selector) { + control.title = title + control.target = self + control.action = action control.bezelStyle = .rounded control.setAccessibilityLabel(title) - return control } private func configureCheckbox(_ control: NSButton, title: String, action: Selector) { @@ -322,7 +456,11 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD private func configureStatusLabel(_ label: NSTextField) { label.textColor = .secondaryLabelColor label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.usesSingleLineMode = false + label.cell?.wraps = true + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } private func configurePopup(_ popup: NSPopUpButton, action: Selector) { @@ -390,56 +528,473 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } } - private func refreshFromSettings() { - historyLabel.stringValue = "History length: \(settings.maxHistoryItems)" - historyStepper.integerValue = settings.maxHistoryItems + private func refreshFromSettings(for change: SettingsModel.Change) { + switch change { + case .maxHistoryItems: + refreshHistoryLimitControls() + case .historyRetention: + refreshHistoryRetentionControl() + case .defaultSortMode: + refreshDefaultSortControl() + case .imageCacheMaxBytes: + refreshCacheControls() + case .includeImageTextInSearch: + includeImageTextButton.state = settings.includeImageTextInSearch ? .on : .off + case .pruneDuplicates: + pruneDuplicatesButton.state = settings.pruneDuplicates ? .on : .off + case .openShortcut: + refreshShortcutControls(openShortcutControls, binding: settings.openShortcut) + refreshShortcutStatusLabel() + case .settingsShortcut: + refreshShortcutControls(settingsShortcutControls, binding: settings.settingsShortcut) + refreshShortcutStatusLabel() + case .launchAtLogin: + refreshLaunchAtLoginControls() + case .status: + refreshStatusIndicators(refreshCloudSyncStatus: false) + case .captureStatus: + refreshCaptureStatusLabel() + case .cloudSync: + refreshCloudSyncControls(refreshStatus: settings.iCloudSyncEnabled && cachedCloudSyncStatus == nil) + case .ignoredApps: + refreshIgnoredAppsTextView() + case .ignoredItemKinds: + refreshAllowedKindControls() + case .keepFirstImage: + keepFirstImageButton.state = settings.keepFirstImage ? .on : .off + case .excludeSensitive: + excludeSensitiveButton.state = settings.excludeSensitive ? .on : .off + case .pauseCapture: + refreshPauseCaptureControls() + case .hideFromScreenCapture: + hideFromScreenCaptureButton.state = settings.hideFromScreenCapture ? .on : .off + case .clearHistoryOnQuit: + clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off + case .pollProfile: + select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue) + case .panelLayout: + select(panelSidePopup, rawValue: settings.panelSide.rawValue) + case .showMenuBarIcon: + refreshVisibilityControls() + case .showDockIcon: + refreshVisibilityControls() + default: + refreshFromSettings(refreshCloudSyncStatus: false) + } + } + + private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) { + #if DEBUG + debugFullRefreshCountValue += 1 + #endif + + refreshHistoryLimitControls() + refreshHistoryRetentionControl() pruneDuplicatesButton.state = settings.pruneDuplicates ? .on : .off keepFirstImageButton.state = settings.keepFirstImage ? .on : .off - select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue) - launchAtLoginButton.state = settings.launchAtLogin ? .on : .off - showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off - showDockIconButton.state = settings.showDockIcon ? .on : .off - launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage - + refreshDefaultSortControl() + refreshLaunchAtLoginControls() + refreshVisibilityControls() + select(panelSidePopup, rawValue: settings.panelSide.rawValue) refreshShortcutControls(openShortcutControls, binding: settings.openShortcut) refreshShortcutControls(settingsShortcutControls, binding: settings.settingsShortcut) - shortcutStatusLabel.stringValue = settings.shortcutStatusMessage.isEmpty ? "Registered" : settings.shortcutStatusMessage - pauseCaptureButton.state = settings.pauseCapture ? .on : .off - captureStatusLabel.stringValue = settings.captureStatusMessage.isEmpty ? "Capture status will appear after the app sees clipboard activity." : settings.captureStatusMessage + refreshPauseCaptureControls() excludeSensitiveButton.state = settings.excludeSensitive ? .on : .off includeImageTextButton.state = settings.includeImageTextInSearch ? .on : .off + refreshAllowedKindControls() + refreshIgnoredAppsTextView() + + clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off + hideFromScreenCaptureButton.state = settings.hideFromScreenCapture ? .on : .off + + select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue) + refreshCacheControls() + refreshStatusIndicators(refreshCloudSyncStatus: refreshCloudSyncStatus) + } + + private func refreshHistoryLimitControls() { + historyLabel.stringValue = "History length: \(settings.maxHistoryItems)" + historyStepper.integerValue = settings.maxHistoryItems + } + + private func refreshHistoryRetentionControl() { + select(historyRetentionPopup, rawValue: settings.historyRetention.rawValue) + } + + private func refreshDefaultSortControl() { + select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue) + } + + private func refreshAllowedKindControls() { for (kind, button) in allowedKindButtons { button.state = settings.ignoredItemKindsRaw.contains(kind.rawValue) ? .off : .on } + } + + private func refreshLaunchAtLoginControls() { + launchAtLoginButton.state = settings.launchAtLogin ? .on : .off + let presentation = Self.launchAtLoginStatusPresentation( + storedStatus: settings.launchAtLoginErrorMessage + ) + launchStatusLabel.stringValue = presentation.message + launchStatusLabel.textColor = presentation.textColor + } + + static func launchAtLoginStatusPresentation( + storedStatus: String + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + guard !storedStatus.isEmpty else { + return ("", .secondaryLabelColor) + } + return (storedStatus, .systemRed) + } + + private func refreshVisibilityControls() { + showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off + showDockIconButton.state = settings.showDockIcon ? .on : .off + } + + private func refreshPauseCaptureControls() { + pauseCaptureButton.state = settings.pauseCapture ? .on : .off + refreshCaptureStatusLabel() + } + + private func refreshCaptureStatusLabel() { + setCaptureStatusLabel(settings.captureStatusMessage) + } + + private func setCaptureStatusLabel(_ message: String) { + let presentation = Self.captureStatusPresentation(storedStatus: message) + captureStatusLabel.stringValue = presentation.message + captureStatusLabel.textColor = presentation.textColor + } + + static func captureStatusPresentation( + storedStatus: String + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + guard !storedStatus.isEmpty else { + return ( + "Capture status will appear after the app sees clipboard activity.", + .secondaryLabelColor + ) + } + return (storedStatus, captureStatusColor(for: storedStatus)) + } + + private static func captureStatusColor(for message: String) -> NSColor { + let lowercasedMessage = message.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ).lowercased() + if lowercasedMessage.hasPrefix("captured") + || lowercasedMessage.hasPrefix("allowed content types updated") + || lowercasedMessage.contains("capture running") + || lowercasedMessage.contains("capture is running") + || lowercasedMessage.contains("capture resumed") { + return .systemGreen + } + if lowercasedMessage.hasPrefix("error") + || lowercasedMessage.contains("failed") + || lowercasedMessage.contains("could not") { + return .systemRed + } + if lowercasedMessage.hasPrefix("skipped") + || lowercasedMessage.contains("ignored") + || lowercasedMessage.contains("paused") + || lowercasedMessage.contains("at least one") { + return .systemOrange + } + return .secondaryLabelColor + } + + private func refreshStatusIndicators(refreshCloudSyncStatus: Bool) { + refreshLaunchAtLoginControls() + refreshShortcutStatusLabel() + refreshCaptureStatusLabel() + refreshAccessibilityPermissionStatusLabel() + refreshPasteStatusLabel() + refreshCloudSyncControls(refreshStatus: refreshCloudSyncStatus) + } + + private func refreshShortcutStatusLabel() { + let presentation = Self.shortcutStatusPresentation( + storedStatus: settings.shortcutStatusMessage + ) + shortcutStatusLabel.stringValue = presentation.message + shortcutStatusLabel.textColor = presentation.textColor + } + + static func shortcutStatusPresentation( + storedStatus: String + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + guard !storedStatus.isEmpty else { + return ("Registered", .systemGreen) + } + return (storedStatus, .systemRed) + } + + private func refreshAccessibilityPermissionStatusLabel() { + let presentation = Self.accessibilityPermissionStatusPresentation( + storedStatus: settings.accessibilityPermissionStatusMessage, + isTrusted: AccessibilityPermissionService.isTrusted + ) + accessibilityStatusLabel.stringValue = presentation.message + accessibilityStatusLabel.textColor = presentation.textColor + } + + static func accessibilityPermissionStatusPresentation( + storedStatus: String, + isTrusted: Bool + ) -> (message: String, textColor: NSColor) { + let trimmedStatus = storedStatus.clipboardTrimmed + guard trimmedStatus.isEmpty else { + return (trimmedStatus, .systemOrange) + } + return isTrusted + ? ("Granted", .systemGreen) + : ("Not granted (clipboard capture still works; paste falls back to copy)", .systemOrange) + } + + private func refreshPasteStatusLabel() { + let presentation = Self.pasteStatusPresentation( + storedStatus: settings.pasteStatusMessage + ) + pasteStatusLabel.stringValue = presentation.message + pasteStatusLabel.textColor = presentation.textColor + } + + static func pasteStatusPresentation( + storedStatus: String + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + guard !storedStatus.isEmpty else { + return ("No paste action yet.", .secondaryLabelColor) + } + return (storedStatus, pasteStatusColor(for: storedStatus)) + } + + private static func pasteStatusColor(for message: String) -> NSColor { + let lowercasedMessage = message.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ).lowercased() + if lowercasedMessage.contains("could not") + || lowercasedMessage.contains("failed") { + return .systemRed + } + if lowercasedMessage.contains("grant accessibility") + || lowercasedMessage.contains("not granted") { + return .systemOrange + } + if lowercasedMessage.hasPrefix("pasted") + || lowercasedMessage.hasPrefix("copied") { + return .systemGreen + } + return .secondaryLabelColor + } + + private func refreshCacheControls() { + cacheSlider.doubleValue = Double(settings.imageCacheMaxBytes) / 1024 / 1024 + cacheLabel.stringValue = "Current cache cap: \(cacheLimitMegabytes) MB" + } + + private var cacheLimitMegabytes: Int { + Int((Double(settings.imageCacheMaxBytes) / 1024 / 1024).rounded()) + } + + private func setDataStatus(_ message: String) { + let presentation = Self.dataStatusPresentation(storedStatus: message) + dataStatusLabel.stringValue = presentation.message + dataStatusLabel.textColor = presentation.textColor + } + + static func dataStatusPresentation( + storedStatus: String + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + guard !storedStatus.isEmpty else { + return ("", .secondaryLabelColor) + } + return (storedStatus, dataStatusColor(for: storedStatus)) + } + + private static func dataStatusColor(for message: String) -> NSColor { + let lowercasedMessage = message.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ).lowercased() + if lowercasedMessage.contains("could not") + || lowercasedMessage.contains("couldn") + || lowercasedMessage.contains("denied") + || lowercasedMessage.contains("error") + || lowercasedMessage.contains("failed") + || lowercasedMessage.contains("invalid") + || lowercasedMessage.contains("missing") + || lowercasedMessage.contains("not permitted") + || lowercasedMessage.contains("unavailable") { + return .systemRed + } + if lowercasedMessage.contains("skipped") { + return .systemOrange + } + if lowercasedMessage.hasPrefix("exported") + || lowercasedMessage.hasPrefix("imported") + || lowercasedMessage.hasPrefix("cleared") { + return .systemGreen + } + return .secondaryLabelColor + } + + private func refreshCloudSyncControls(refreshStatus: Bool) { + iCloudSyncButton.state = settings.iCloudSyncEnabled ? .on : .off + let cloudStatus = cloudSyncStatus(refresh: refreshStatus) + let presentation = Self.cloudSyncStatusPresentation( + storedStatus: settings.cloudSyncStatusMessage, + isSyncEnabled: settings.iCloudSyncEnabled, + cloudStatus: cloudStatus + ) + cloudSyncStatusLabel.stringValue = presentation.message + cloudSyncStatusLabel.textColor = presentation.textColor + let cloudActionsEnabled = settings.iCloudSyncEnabled && cloudStatus?.isAvailable == true + iCloudSyncNowButton.isEnabled = cloudActionsEnabled + iCloudRestoreButton.isEnabled = cloudActionsEnabled + iCloudRevealButton.isEnabled = cloudActionsEnabled + } + + static func cloudSyncStatusPresentation( + storedStatus: String, + isSyncEnabled: Bool, + cloudStatus: ClipboardCloudSyncStatus? + ) -> (message: String, textColor: NSColor) { + let storedStatus = storedStatus.clipboardTrimmed + if !storedStatus.isEmpty { + return (storedStatus, cloudSyncStatusColor(for: storedStatus)) + } + + if !isSyncEnabled { + return ("iCloud Sync is off.", .secondaryLabelColor) + } + + guard let cloudStatus else { + return ("iCloud Sync is unavailable.", .systemOrange) + } + + return ( + cloudStatus.message, + cloudStatus.isAvailable ? .secondaryLabelColor : .systemOrange + ) + } + + private static func cloudSyncStatusColor(for message: String) -> NSColor { + let lowercasedMessage = message.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ).lowercased() + if lowercasedMessage.contains("failed") { + return .systemRed + } + if lowercasedMessage.contains("unavailable") + || lowercasedMessage.contains("turn on") + || lowercasedMessage.contains("skipped") { + return .systemOrange + } + if lowercasedMessage.hasPrefix("synced") + || lowercasedMessage.hasPrefix("restored") + || lowercasedMessage.hasPrefix("opened") { + return .systemGreen + } + return .secondaryLabelColor + } + + private func cloudSyncStatus(refresh: Bool) -> ClipboardCloudSyncStatus? { + guard settings.iCloudSyncEnabled else { + cachedCloudSyncStatus = nil + return nil + } + + if refresh || cachedCloudSyncStatus == nil { + cachedCloudSyncStatus = cloudSyncService.status() + } + return cachedCloudSyncStatus + } + + private func refreshIgnoredAppsTextView(force: Bool = false) { + #if DEBUG + debugIgnoredAppsRefreshCountValue += 1 + #endif + + guard force || !isEditingIgnoredApps else { return } + let ignoredAppsText = settings.ignoredApps.joined(separator: ", ") if ignoredAppsTextView.string != ignoredAppsText { ignoredAppsTextView.string = ignoredAppsText } - - clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off - let hasAccessibilityPermission = AccessibilityPermissionService.isTrusted - let permissionStatus = hasAccessibilityPermission - ? "Granted" - : "Not granted (clipboard capture still works; paste falls back to copy)" - accessibilityStatusLabel.stringValue = permissionStatus - accessibilityStatusLabel.textColor = hasAccessibilityPermission ? .systemGreen : .systemOrange - pasteStatusLabel.stringValue = settings.pasteStatusMessage.isEmpty ? "No paste action yet." : settings.pasteStatusMessage - - select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue) - cacheSlider.doubleValue = Double(settings.imageCacheMaxBytes) / 1024 / 1024 - cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB" } - private func refreshShortcutControls(_ controls: ShortcutControlSet?, binding: ShortcutBinding) { + private func ignoredApps(from text: String) -> [String] { + text + .split(whereSeparator: { $0 == "," || $0 == "\n" }) + .map { $0.clipboardTrimmed } + .filter { !$0.isEmpty } + } + + private var isEditingIgnoredApps: Bool { + ignoredAppsEditorIsEditing || ignoredAppsTextView.window?.firstResponder === ignoredAppsTextView + } + + private func refreshShortcutControls( + _ controls: ShortcutControlSet?, + binding: ShortcutBinding, + forceKeyRefresh: Bool = false + ) { guard let controls else { return } - controls.keyField.stringValue = binding.key.uppercased() + if forceKeyRefresh || !isEditingShortcutKeyField(controls.keyField) { + controls.keyField.stringValue = binding.key.uppercased() + } controls.command.state = binding.has(.command) ? .on : .off controls.option.state = binding.has(.option) ? .on : .off controls.control.state = binding.has(.control) ? .on : .off controls.shift.state = binding.has(.shift) ? .on : .off } + private func isEditingShortcutKeyField(_ field: NSTextField) -> Bool { + if editingShortcutFieldTag == field.tag { return true } + guard let editor = field.currentEditor() else { return false } + return field.window?.firstResponder === editor + } + + private func commitPendingEditorDrafts() { + commitIgnoredAppsDraftIfNeeded() + commitShortcutDraftIfNeeded(openShortcutControls) + commitShortcutDraftIfNeeded(settingsShortcutControls) + } + + private func commitIgnoredAppsDraftIfNeeded() { + guard ignoredAppsEditorHasDraft else { + ignoredAppsEditorIsEditing = false + ignoredAppsEditorHasDraft = false + refreshIgnoredAppsTextView(force: true) + return + } + + settings.ignoredApps = ignoredApps(from: ignoredAppsTextView.string) + ignoredAppsEditorIsEditing = false + ignoredAppsEditorHasDraft = false + refreshIgnoredAppsTextView(force: true) + } + + private func commitShortcutDraftIfNeeded(_ controls: ShortcutControlSet?) { + guard let controls, isEditingShortcutKeyField(controls.keyField) else { return } + shortcutChanged(controls.keyField) + if editingShortcutFieldTag == controls.keyField.tag { + editingShortcutFieldTag = nil + } + } + private func select(_ popup: NSPopUpButton, rawValue: Int) { for item in popup.itemArray where item.representedObject as? Int == rawValue { popup.select(item) @@ -449,7 +1004,14 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD @objc private func historyLengthChanged() { settings.maxHistoryItems = historyStepper.integerValue - historyLabel.stringValue = "History length: \(settings.maxHistoryItems)" + 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() { @@ -468,9 +1030,14 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } @objc private func launchAtLoginChanged() { - settings.launchAtLogin = launchAtLoginButton.state == .on + 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?.refreshFromSettings() + self?.refreshLaunchAtLoginControls() } } @@ -480,6 +1047,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD if !shouldShowMenuBarIcon && !settings.showDockIcon { settings.showDockIcon = true } + refreshVisibilityControls() } @objc private func showDockIconChanged() { @@ -488,6 +1056,14 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD 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 shortcutChanged(_ sender: NSControl) { @@ -497,34 +1073,66 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD let current = isOpenShortcut ? settings.openShortcut : settings.settingsShortcut let parsed = parseShortcut(controls.keyField.stringValue) - var flags = NSEvent.ModifierFlags(rawValue: parsed?.modifierFlags ?? current.modifierFlags) - setFlag(&flags, .command, controls.command.state == .on || parsed?.has(.command) == true) - setFlag(&flags, .option, controls.option.state == .on || parsed?.has(.option) == true) - setFlag(&flags, .control, controls.control.state == .on || parsed?.has(.control) == true) - setFlag(&flags, .shift, controls.shift.state == .on || parsed?.has(.shift) == true) + let hasExplicitModifiers = shortcutInputHasExplicitModifiers(controls.keyField.stringValue) + var flags = NSEvent.ModifierFlags(rawValue: hasExplicitModifiers ? parsed?.modifierFlags ?? 0 : current.modifierFlags) + setFlag(&flags, .command, hasExplicitModifiers ? parsed?.has(.command) == true : controls.command.state == .on) + setFlag(&flags, .option, hasExplicitModifiers ? parsed?.has(.option) == true : controls.option.state == .on) + setFlag(&flags, .control, hasExplicitModifiers ? parsed?.has(.control) == true : controls.control.state == .on) + setFlag(&flags, .shift, hasExplicitModifiers ? parsed?.has(.shift) == true : controls.shift.state == .on) - let key = parsed?.key ?? String(controls.keyField.stringValue.clipboardTrimmed.prefix(1)).lowercased() - guard !key.isEmpty else { - refreshFromSettings() + guard let key = parsed?.key ?? shortcutKey(from: controls.keyField.stringValue) else { + settings.setShortcutStatus(message: invalidShortcutStatusMessage( + for: controls.keyField.stringValue, + modifierFlags: flags + ) ?? "") + refreshShortcutControls(controls, binding: current, forceKeyRefresh: true) + refreshShortcutStatusLabel() return } let updated = ShortcutBinding(key: key, modifierFlags: flags.rawValue) + if let validationMessage = shortcutValidationMessage(for: updated, isOpenShortcut: isOpenShortcut) { + settings.setShortcutStatus(message: validationMessage) + refreshShortcutControls(controls, binding: current, forceKeyRefresh: true) + refreshShortcutStatusLabel() + return + } + + settings.setShortcutStatus(message: "") if isOpenShortcut { settings.openShortcut = updated } else { settings.settingsShortcut = updated } - refreshFromSettings() + refreshShortcutControls( + controls, + binding: updated, + forceKeyRefresh: sender === controls.keyField || !isEditingShortcutKeyField(controls.keyField) + ) + refreshShortcutStatusLabel() + } + + func controlTextDidBeginEditing(_ notification: Notification) { + guard let field = notification.object as? NSTextField, field.tag == 100 || field.tag == 200 else { return } + editingShortcutFieldTag = field.tag } func controlTextDidEndEditing(_ notification: Notification) { guard let field = notification.object as? NSTextField, field.tag == 100 || field.tag == 200 else { return } shortcutChanged(field) + if editingShortcutFieldTag == field.tag { + editingShortcutFieldTag = nil + } } @objc private func pauseCaptureChanged() { - settings.pauseCapture = pauseCaptureButton.state == .on + if pauseCaptureButton.state == .on { + settings.pauseCaptureUntil = nil + settings.pauseCapture = true + } else { + settings.pauseCapture = false + settings.pauseCaptureUntil = nil + } } @objc private func excludeSensitiveChanged() { @@ -543,21 +1151,45 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } else if !ignored.contains(kind.rawValue) { ignored.append(kind.rawValue) } + let visibleKindRawValues = Set(allowedKindButtons.map { $0.0.rawValue }) + let ignoredVisibleKindRawValues = Set(ignored.filter { visibleKindRawValues.contains($0) }) + if visibleKindRawValues.isSubset(of: ignoredVisibleKindRawValues) { + let message = Self.allowedContentTypesValidationMessage + sender.state = .on + settings.setCaptureStatus(message: message) + setCaptureStatusLabel(message) + return + } settings.ignoredItemKindsRaw = ignored + if settings.captureStatusMessage == Self.allowedContentTypesValidationMessage { + settings.setCaptureStatus(message: Self.allowedContentTypesUpdatedMessage) + } + } + + func textDidBeginEditing(_ notification: Notification) { + guard notification.object as? NSTextView === ignoredAppsTextView else { return } + ignoredAppsEditorIsEditing = true + ignoredAppsEditorHasDraft = false } func textDidChange(_ notification: Notification) { guard notification.object as? NSTextView === ignoredAppsTextView else { return } - settings.ignoredApps = ignoredAppsTextView.string - .split(whereSeparator: { $0 == "," || $0 == "\n" }) - .map { $0.clipboardTrimmed } - .filter { !$0.isEmpty } + ignoredAppsEditorHasDraft = true + } + + func textDidEndEditing(_ notification: Notification) { + guard notification.object as? NSTextView === ignoredAppsTextView else { return } + commitIgnoredAppsDraftIfNeeded() } @objc private func clearHistoryOnQuitChanged() { settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on } + @objc private func hideFromScreenCaptureChanged() { + settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on + } + @objc private func requestAccessibilityAccess() { _ = AccessibilityPermissionService.requestPromptIfNeeded() if !AccessibilityPermissionService.isTrusted { @@ -575,7 +1207,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD ? "" : "Accessibility permission not granted." ) - refreshFromSettings() + refreshAccessibilityPermissionStatusLabel() } @objc private func pollProfileChanged() { @@ -586,14 +1218,110 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD } @objc private func cacheLimitChanged() { - settings.imageCacheMaxBytes = Int64(cacheSlider.doubleValue * 1024 * 1024) - cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB" + 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() { NSWorkspace.shared.open(ClipboardStore.storageDirectory()) } + @objc private func exportClipboardArchive() { + let panel = NSSavePanel() + panel.title = "Export ClipBored Archive" + panel.nameFieldStringValue = defaultArchiveFileName() + configureArchivePanel(panel) + + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let summary = try store.exportArchive(to: url) + setDataStatus("Exported \(summary.itemCount) clips and \(summary.sidecarCount) attachments.") + } catch { + setDataStatus(error.localizedDescription) + } + } + + @objc private func importClipboardArchive() { + let panel = NSOpenPanel() + panel.title = "Import ClipBored Archive" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + configureArchivePanel(panel) + + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let summary = try store.importArchive(from: url) + var message = "Imported \(summary.itemCount) clips and \(summary.sidecarCount) attachments." + if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 { + message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments." + } + setDataStatus(message) + } catch { + setDataStatus(error.localizedDescription) + } + } + + @objc private func pushICloudSyncArchive() { + guard settings.iCloudSyncEnabled else { + settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.") + return + } + + do { + let summary = try cloudSyncService.push(store: store) + cachedCloudSyncStatus = cloudSyncService.status() + settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips and \(summary.sidecarCount) attachments to iCloud.") + } catch { + settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + + @objc private func pullICloudSyncArchive() { + guard settings.iCloudSyncEnabled else { + settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.") + return + } + + do { + let summary = try cloudSyncService.pull(store: store) + cachedCloudSyncStatus = cloudSyncService.status() + var message = "Restored \(summary.itemCount) clips and \(summary.sidecarCount) attachments from iCloud." + if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 { + message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments." + } + settings.setCloudSyncStatus(message: message) + } catch { + settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + + @objc private func revealICloudSyncFile() { + do { + let url = try cloudSyncService.syncArchiveURL() + if FileManager.default.fileExists(atPath: url.path) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else { + NSWorkspace.shared.open(url.deletingLastPathComponent()) + } + cachedCloudSyncStatus = cloudSyncService.status() + settings.setCloudSyncStatus(message: "Opened iCloud sync location.") + } catch { + settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + @objc private func clearClipboardHistory() { guard confirmDestructiveAction( title: "Clear Clipboard History?", @@ -602,6 +1330,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD ) else { return } store.removeAll() cacheService.clearTemporaryPreviews() + setDataStatus("Cleared clipboard history.") } @objc private func clearThumbnailCache() { @@ -611,9 +1340,16 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD buttonTitle: "Clear Cache" ) else { return } cacheService.clearCache() + setDataStatus("Cleared thumbnail cache.") } 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 alert.informativeText = message @@ -623,6 +1359,16 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD return alert.runModal() == .alertFirstButtonReturn } + private func configureArchivePanel(_ panel: NSSavePanel) { + panel.setValue([ClipboardArchiveService.fileExtension], forKey: "allowedFileTypes") + } + + private func defaultArchiveFileName() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmm" + return "ClipBored-\(formatter.string(from: Date())).\(ClipboardArchiveService.fileExtension)" + } + private func setFlag(_ flags: inout NSEvent.ModifierFlags, _ flag: NSEvent.ModifierFlags, _ enabled: Bool) { if enabled { flags.insert(flag) @@ -647,9 +1393,482 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD .replacingOccurrences(of: "⇧", with: "") .clipboardTrimmed - guard let key = plain.first else { return nil } + guard plain.count == 1, let key = plain.first else { return nil } return ShortcutBinding(key: String(key), modifierFlags: flags.rawValue) } + + private func shortcutInputHasExplicitModifiers(_ text: String) -> Bool { + let cleaned = text.clipboardTrimmed + return cleaned.contains("⌘") + || cleaned.contains("⌥") + || cleaned.contains("⌃") + || cleaned.contains("⇧") + } + + private func shortcutKey(from text: String) -> String? { + let cleaned = text.clipboardTrimmed + guard cleaned.count == 1 else { return nil } + guard !["⌘", "⌥", "⌃", "⇧"].contains(cleaned) else { return nil } + return cleaned.lowercased() + } + + private func invalidShortcutStatusMessage( + for text: String, + modifierFlags: NSEvent.ModifierFlags + ) -> String? { + let cleaned = text.clipboardTrimmed + guard !cleaned.isEmpty else { return nil } + + var explicitFlags = NSEvent.ModifierFlags() + if cleaned.contains("⌘") { explicitFlags.insert(.command) } + if cleaned.contains("⌥") { explicitFlags.insert(.option) } + if cleaned.contains("⌃") { explicitFlags.insert(.control) } + if cleaned.contains("⇧") { explicitFlags.insert(.shift) } + + let plain = cleaned.replacingOccurrences(of: "⌘", with: "") + .replacingOccurrences(of: "⌥", with: "") + .replacingOccurrences(of: "⌃", with: "") + .replacingOccurrences(of: "⇧", with: "") + .clipboardTrimmed + + let display: String + if plain.isEmpty { + display = cleaned + } else { + let flags = explicitFlags.isEmpty ? modifierFlags : explicitFlags + display = ShortcutBinding(key: plain, modifierFlags: flags.rawValue).displayText + } + return ShortcutManager.RegistrationStatus.unsupportedShortcut(display).message + } + + private func shortcutValidationMessage(for binding: ShortcutBinding, isOpenShortcut: Bool) -> String? { + if let failure = ShortcutManager.validationFailure(for: binding) { + return failure.message + } + + let otherBinding = isOpenShortcut ? settings.settingsShortcut : settings.openShortcut + if binding == otherBinding { + return ShortcutManager.RegistrationStatus.conflict(binding.displayText).message + } + + if binding == ShortcutManager.stackCaptureShortcut { + return ShortcutManager.RegistrationStatus.conflict(binding.displayText).message + } + + 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 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/AppDelegateTests.swift b/tests/clipboredtests/AppDelegateTests.swift index 882e1db..70eccb1 100644 --- a/tests/clipboredtests/AppDelegateTests.swift +++ b/tests/clipboredtests/AppDelegateTests.swift @@ -78,9 +78,14 @@ final class AppDelegateTests: XCTestCase { "Captured text from Safari.", "-", "Show Clipboard", + "New Collection", + "Stack Capture", settingsTitle, "-", "Pause Capture", + "Pause for 5 Minutes", + "Pause for 30 Minutes", + "Pause for 1 Hour", "-", "Quit ClipBored" ] @@ -91,9 +96,25 @@ final class AppDelegateTests: XCTestCase { XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true) XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == true) + XCTAssertNil(menu.items.first { $0.title == "New Text Clip" }) + + let newCollection = menu.items.first { $0.title == "New Collection" } + XCTAssertEqual(newCollection?.keyEquivalent, "n") + XCTAssertTrue(newCollection?.keyEquivalentModifierMask.contains(.command) == true) + XCTAssertTrue(newCollection?.keyEquivalentModifierMask.contains(.shift) == true) + + let stackCapture = menu.items.first { $0.title == "Stack Capture" } + XCTAssertEqual(stackCapture?.keyEquivalent, "c") + XCTAssertTrue(stackCapture?.keyEquivalentModifierMask.contains(.command) == true) + XCTAssertTrue(stackCapture?.keyEquivalentModifierMask.contains(.shift) == true) + let settings = menu.items.first { $0.title == settingsTitle } XCTAssertEqual(settings?.keyEquivalent, ",") XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true) + + let pauseCapture = menu.items.first { $0.title == "Pause Capture" } + XCTAssertEqual(pauseCapture?.keyEquivalent, "t") + XCTAssertTrue(pauseCapture?.keyEquivalentModifierMask.contains(.command) == true) } func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() { @@ -116,8 +137,55 @@ final class AppDelegateTests: XCTestCase { XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip") XCTAssertEqual(presentation.detail, "Capture is paused.") - XCTAssertEqual(menu.items.first { $0.title == "Resume Capture" }?.state, .on) + let resumeCapture = menu.items.first { $0.title == "Resume Capture" } + XCTAssertEqual(resumeCapture?.state, .on) + XCTAssertEqual(resumeCapture?.keyEquivalent, "t") + XCTAssertTrue(resumeCapture?.keyEquivalentModifierMask.contains(.command) == true) XCTAssertNil(menu.items.first { $0.title == "Pause Capture" }) + XCTAssertNil(menu.items.first { $0.title == "Pause for 5 Minutes" }) + } + + func testStatusMenuPresentationShowsTimedPauseRemainingBeforeOlderStatuses() { + let now = Date(timeIntervalSince1970: 1_000) + let presentation = AppDelegate.statusMenuPresentation( + historyCount: 12, + isCapturePaused: true, + pauseCaptureUntil: now.addingTimeInterval(5 * 60), + now: now, + captureStatus: "Captured text from Safari.", + pasteStatus: "", + shortcutStatus: "", + accessibilityStatus: "", + launchAtLoginStatus: "" + ) + + XCTAssertEqual(presentation.summary, "Capture Paused - 12 clips") + XCTAssertEqual(presentation.detail, "Capture is paused for 5 more minutes.") + } + + func testCapturePauseExpiryOnlyAppliesToExpiredTimedPauses() { + let now = Date(timeIntervalSince1970: 1_000) + + XCTAssertTrue(AppDelegate.shouldResumeExpiredCapturePause( + isCapturePaused: true, + pauseCaptureUntil: now, + now: now + )) + XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause( + isCapturePaused: true, + pauseCaptureUntil: now.addingTimeInterval(1), + now: now + )) + XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause( + isCapturePaused: false, + pauseCaptureUntil: now.addingTimeInterval(-1), + now: now + )) + XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause( + isCapturePaused: true, + pauseCaptureUntil: nil, + now: now + )) } func testStatusMenuPresentationTruncatesLongStatusText() { diff --git a/tests/clipboredtests/ClipboardCloudSyncServiceTests.swift b/tests/clipboredtests/ClipboardCloudSyncServiceTests.swift new file mode 100644 index 0000000..ebff0f1 --- /dev/null +++ b/tests/clipboredtests/ClipboardCloudSyncServiceTests.swift @@ -0,0 +1,144 @@ +import Foundation +import XCTest +@testable import ClipBored + +final class ClipboardCloudSyncServiceTests: XCTestCase { + private var tempRoot: URL! + private var defaultsSuites: [String] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("clipbored-cloud-sync-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + for suite in defaultsSuites { + UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) + } + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + defaultsSuites = [] + tempRoot = nil + try super.tearDownWithError() + } + + func testUnavailableContainerReportsStatusAndThrows() { + let service = ClipboardCloudSyncService(containerProvider: { nil }) + + let status = service.status() + + XCTAssertFalse(status.isAvailable) + XCTAssertNil(status.archiveURL) + XCTAssertTrue(status.message.contains("iCloud Sync is unavailable")) + XCTAssertThrowsError(try service.syncArchiveURL()) { error in + XCTAssertEqual(error as? ClipboardCloudSyncError, .unavailable) + } + } + + func testPushWritesArchiveToPrivateDocumentsFolder() throws { + let environment = try makeStoreEnvironment(named: "source") + environment.store.upsert(makeItem("cloud note", created: Date(timeIntervalSince1970: 10))) + environment.store.flushPersistenceForTesting() + + let cloudRoot = tempRoot.appendingPathComponent("cloud", isDirectory: true) + let service = ClipboardCloudSyncService(containerProvider: { cloudRoot }) + + let summary = try service.push(store: environment.store) + let archiveURL = try service.syncArchiveURL() + let status = service.status() + + XCTAssertEqual(summary.itemCount, 1) + XCTAssertEqual( + archiveURL, + cloudRoot + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent(AppConfiguration.appName, isDirectory: true) + .appendingPathComponent(ClipboardCloudSyncService.archiveFileName) + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: archiveURL.path)) + XCTAssertEqual(try posixPermissions(archiveURL), 0o600) + XCTAssertTrue(status.isAvailable) + XCTAssertEqual(status.archiveURL, archiveURL) + XCTAssertNotNil(status.lastModifiedAt) + } + + func testPullImportsExistingCloudArchiveIntoAnotherStore() throws { + let source = try makeStoreEnvironment(named: "source") + let sourceItem = makeItem("shared through icloud", created: Date(timeIntervalSince1970: 20)) + source.store.upsert(sourceItem) + source.store.flushPersistenceForTesting() + + let cloudRoot = tempRoot.appendingPathComponent("cloud", isDirectory: true) + let service = ClipboardCloudSyncService(containerProvider: { cloudRoot }) + try service.push(store: source.store) + + let destination = try makeStoreEnvironment(named: "destination") + let summary = try service.pull(store: destination.store) + destination.store.flushPersistenceForTesting() + + XCTAssertEqual(summary.itemCount, 1) + XCTAssertEqual(destination.store.items.count, 1) + XCTAssertEqual(destination.store.items.first?.id, sourceItem.id) + XCTAssertEqual(destination.store.items.first?.payload, "shared through icloud") + } + + func testPullWithoutRemoteArchiveThrowsNoRemoteArchive() throws { + let destination = try makeStoreEnvironment(named: "destination") + let cloudRoot = tempRoot.appendingPathComponent("empty-cloud", isDirectory: true) + let service = ClipboardCloudSyncService(containerProvider: { cloudRoot }) + + XCTAssertThrowsError(try service.pull(store: destination.store)) { error in + guard case ClipboardCloudSyncError.noRemoteArchive(let url) = error else { + return XCTFail("Expected noRemoteArchive, got \(error)") + } + XCTAssertEqual(url.lastPathComponent, ClipboardCloudSyncService.archiveFileName) + } + } + + private func makeStoreEnvironment(named name: String) throws -> (settings: SettingsModel, store: ClipboardStore) { + let suiteName = "com.clipbored.cloudsync.\(name).\(UUID().uuidString)" + defaultsSuites.append(suiteName) + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + + let settings = SettingsModel(defaults: defaults) + settings.maxHistoryItems = 50 + settings.historyRetention = .forever + let baseURL = tempRoot.appendingPathComponent(name, isDirectory: true) + let encryptionService = ClipboardEncryptionService(keyProvider: { nil }) + let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: encryptionService) + let store = ClipboardStore( + settings: settings, + cacheService: cacheService, + baseURL: baseURL, + encryptionService: encryptionService + ) + return (settings, store) + } + + private func makeItem(_ payload: String, created: Date) -> ClipboardItem { + ClipboardItem( + id: UUID(), + kind: .text, + displayText: payload, + payload: payload, + payloadHash: String(payload.hashValue), + createdAt: created, + lastUsedAt: created, + useCount: 1, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil, + isPinned: false + ) + } + + private func posixPermissions(_ url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return try XCTUnwrap(attributes[.posixPermissions] as? Int) + } +} diff --git a/tests/clipboredtests/ClipboardEncryptionServiceTests.swift b/tests/clipboredtests/ClipboardEncryptionServiceTests.swift index ac26bd9..499d0b1 100644 --- a/tests/clipboredtests/ClipboardEncryptionServiceTests.swift +++ b/tests/clipboredtests/ClipboardEncryptionServiceTests.swift @@ -66,6 +66,25 @@ final class ClipboardEncryptionServiceTests: XCTestCase { XCTAssertEqual(service.protectData(Data("available only in memory".utf8)), Data("available only in memory".utf8)) } + func testSystemKeychainBypassIsEnabledForTestsAndExplicitEnvironment() { + XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain( + environment: ["XCTestConfigurationFilePath": "/tmp/ClipBoredTests.xctestconfiguration"], + arguments: ["/tmp/ClipBoredTests"] + )) + XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain( + environment: ["CLIPBORED_DISABLE_KEYCHAIN": "1"], + arguments: ["/tmp/ClipBored"] + )) + XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain( + environment: [:], + arguments: ["/tmp/ClipBoredPackageTests.xctest/Contents/MacOS/ClipBoredPackageTests"] + )) + XCTAssertFalse(ClipboardEncryptionService.shouldBypassSystemKeychain( + environment: [:], + arguments: ["/Applications/ClipBored.app/Contents/MacOS/ClipBored"] + )) + } + private func makeService(byte: UInt8) -> ClipboardEncryptionService { let keyData = Data(repeating: byte, count: 32) return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) }) diff --git a/tests/clipboredtests/ClipboardMonitorServiceTests.swift b/tests/clipboredtests/ClipboardMonitorServiceTests.swift index 30edd48..c469cff 100644 --- a/tests/clipboredtests/ClipboardMonitorServiceTests.swift +++ b/tests/clipboredtests/ClipboardMonitorServiceTests.swift @@ -20,10 +20,11 @@ final class ClipboardMonitorServiceTests: XCTestCase { func testClampedIntervalEnforcesResponsiveMinimum() { let settings = SettingsModel(defaults: makeTestDefaults()) settings.pollProfile = .responsive + let (store, cacheService) = makeStoreAndCache(settings: settings) let monitor = ClipboardMonitorService( - store: makeStore(settings: settings), - cacheService: ClipboardCacheService(), + store: store, + cacheService: cacheService, settings: settings ) @@ -36,10 +37,11 @@ final class ClipboardMonitorServiceTests: XCTestCase { func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() { let settings = SettingsModel(defaults: makeTestDefaults()) settings.pollProfile = .balanced + let (store, cacheService) = makeStoreAndCache(settings: settings) let monitor = ClipboardMonitorService( - store: makeStore(settings: settings), - cacheService: ClipboardCacheService(), + store: store, + cacheService: cacheService, settings: settings ) @@ -108,6 +110,50 @@ final class ClipboardMonitorServiceTests: XCTestCase { XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1) } + func testPollNowReportsStoredCapturedItemForStackCapture() { + let settings = SettingsModel(defaults: makeTestDefaults()) + settings.pruneDuplicates = true + let (store, cacheService) = makeStoreAndCache(settings: settings) + let monitor = ClipboardMonitorService( + store: store, + cacheService: cacheService, + settings: settings + ) + let text = "Stack capture duplicate merge \(UUID().uuidString)" + let existing = ClipboardItem( + id: UUID(), + kind: .text, + displayText: text, + payload: text, + payloadHash: store.hashString(text), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + store.upsert(existing) + + let reported = expectation(description: "stored captured item reported") + var reportedItem: ClipboardItem? + monitor.onCapturedItem = { item in + guard item.payload == text else { return } + reportedItem = item + reported.fulfill() + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + XCTAssertTrue(pasteboard.setString(text, forType: .string)) + + monitor.pollNowAndWait() + wait(for: [reported], timeout: 1.0) + + XCTAssertEqual(reportedItem?.id, existing.id) + XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1) + } + func testPollNowCapturesCodeSnippetAsCode() { let settings = SettingsModel(defaults: makeTestDefaults()) settings.pruneDuplicates = false @@ -157,7 +203,7 @@ final class ClipboardMonitorServiceTests: XCTestCase { thumbnailPath: nil ) - XCTAssertEqual(PasteActionService().copy(item), .copied) + XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied) monitor.pollNowAndWait() RunLoop.main.run(until: Date().addingTimeInterval(0.05)) @@ -798,7 +844,9 @@ final class ClipboardMonitorServiceTests: XCTestCase { private func makeTestDefaults() -> UserDefaults { let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)" suiteNames.append(suiteName) - return UserDefaults(suiteName: suiteName)! + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(HistoryRetention.forever.rawValue, forKey: SettingsModel.Keys.historyRetention) + return defaults } private func makeStore(settings: SettingsModel) -> ClipboardStore { diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index f35a983..e7fa4d0 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -1,16 +1,80 @@ +import AppKit import XCTest @testable import ClipBored final class ClipboardPanelControllerTests: XCTestCase { - func testPanelFrameUsesFullWidthBottomShelf() { + 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) - XCTAssertEqual(frames.shown.minX, screenFrame.minX) XCTAssertEqual(frames.shown.maxX, screenFrame.maxX) + XCTAssertEqual(frames.shown.width, 336) XCTAssertEqual(frames.shown.minY, screenFrame.minY) - XCTAssertEqual(frames.shown.height, 408) - XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, screenFrame.maxY) + XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1) + XCTAssertEqual(frames.hidden.minY, frames.shown.minY) + } + + func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() { + XCTAssertEqual( + ClipboardPanelController.selectedOpenScreen( + explicit: "status-item-screen", + preferred: nil, + pointer: "pointer-screen", + fallback: "fallback-screen" + ), + "status-item-screen" + ) + } + + func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() { + XCTAssertEqual( + ClipboardPanelController.selectedOpenScreen( + explicit: Optional.none, + preferred: nil, + pointer: "pointer-screen", + fallback: "fallback-screen" + ), + "pointer-screen" + ) + } + + func testReflowScreenSelectionKeepsCurrentPanelScreenAheadOfPointer() { + XCTAssertEqual( + ClipboardPanelController.selectedReflowScreen( + currentPanel: "current-panel-screen", + lastKnown: "last-known-screen", + preferred: nil, + pointer: "pointer-screen", + fallback: "fallback-screen" + ), + "current-panel-screen" + ) + XCTAssertEqual( + ClipboardPanelController.selectedReflowScreen( + currentPanel: Optional.none, + lastKnown: "last-known-screen", + preferred: nil, + pointer: "pointer-screen", + fallback: "fallback-screen" + ), + "last-known-screen" + ) } func testPanelFrameUsesVisibleFrameAroundDock() { @@ -19,33 +83,48 @@ final class ClipboardPanelControllerTests: XCTestCase { let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - XCTAssertEqual(frames.shown.minX, visibleFrame.minX) XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX) - XCTAssertEqual(frames.shown.minY, visibleFrame.minY) - XCTAssertEqual(frames.shown.height, 408) + XCTAssertEqual(frames.shown.minX, visibleFrame.maxX - 336) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.width, 336) } - func testPanelFrameSitsAboveVisibleBottomDock() { + func testPanelFrameUsesAvailableHeightWhenBottomDockIsVisible() { let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - XCTAssertEqual(frames.shown.minX, visibleFrame.minX) XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX) - XCTAssertEqual(frames.shown.minY, visibleFrame.minY) - XCTAssertEqual(frames.shown.height, 408) - XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.width, 336) + XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1) } - func testPanelFrameClampsTallDisplaysToShelfMaximum() { + func testPanelFrameTouchesScreenBottomWhenBottomDockIsAutoHidden() { + let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953) + + let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) + + XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.width, 336) + XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1) + } + + func testPanelFrameKeepsSideShelfWidthOnTallDisplays() { let screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000) let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame) - XCTAssertEqual(frames.shown.width, 3008) - XCTAssertEqual(frames.shown.height, 430) + XCTAssertEqual(frames.shown.width, 336) + XCTAssertEqual(frames.shown.height, 2000) XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxX, screenFrame.maxX) } func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() { @@ -54,9 +133,64 @@ final class ClipboardPanelControllerTests: XCTestCase { let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) - XCTAssertEqual(frames.shown.minY, visibleFrame.minY) - XCTAssertEqual(frames.shown.height, visibleFrame.height) - XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.width, 320) + XCTAssertLessThanOrEqual(frames.shown.width, visibleFrame.width) + XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1) + } + + func testPanelFrameIgnoresLegacyPreferredResizableHeight() { + let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) + + let frames = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + preferredHeight: 600 + ) + + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.height, 957) + XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1) + } + + func testPanelFrameLegacyPreferredHeightDoesNotAffectSideShelf() { + let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + + let tooTall = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + preferredHeight: 900 + ) + let tooShort = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + preferredHeight: 120 + ) + + XCTAssertEqual(tooTall.shown.height, 982) + XCTAssertEqual(tooShort.shown.height, 982) + XCTAssertEqual(tooTall.shown.width, 336) + XCTAssertEqual(tooShort.shown.width, 336) + } + + func testPanelFrameLegacyPreferredHeightDoesNotOverflowTinyVisibleFrame() { + let screenFrame = CGRect(x: 0, y: 0, width: 640, height: 320) + let visibleFrame = CGRect(x: 0, y: 48, width: 640, height: 220) + + let frames = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + preferredHeight: 620 + ) + + XCTAssertEqual(frames.shown.height, 268) + XCTAssertEqual(frames.shown.width, 320) + XCTAssertLessThanOrEqual(frames.shown.height, visibleFrame.maxY - screenFrame.minY) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) } func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() { @@ -68,48 +202,161 @@ final class ClipboardPanelControllerTests: XCTestCase { let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame) XCTAssertEqual(frames.shown, first.shown) XCTAssertEqual(frames.hidden, first.hidden) - XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1) + XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1) } } func testPanelAnimationProfileStaysShortForSixtyFpsFeel() { let profile = ClipboardPanelController.animationProfile - XCTAssertEqual(profile.showDuration, 0.16) - XCTAssertEqual(profile.hideDuration, 0.12) - XCTAssertEqual(profile.reflowDuration, 0.10) - XCTAssertLessThanOrEqual(profile.showDuration * 60, 10) - XCTAssertLessThanOrEqual(profile.hideDuration * 60, 8) - XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 6) + 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 testReflowPlanMovesOpenPanelAboveNewBottomDockVisibleFrame() { + func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() { + let behavior = ClipboardPanelController.panelCollectionBehavior + + XCTAssertTrue(behavior.contains(.moveToActiveSpace)) + XCTAssertTrue(behavior.contains(.fullScreenAuxiliary)) + XCTAssertTrue(behavior.contains(.transient)) + 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, 164, accuracy: 0.5) + XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips") + XCTAssertTrue(controller.debugSearchFieldIsVisible) + XCTAssertFalse(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.minX, visibleFrame.minX) XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX) - XCTAssertEqual(plan.frame.minY, visibleFrame.minY) - XCTAssertEqual(plan.frame.height, 408) + XCTAssertEqual(plan.frame.minY, screenFrame.minY) + XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY) + XCTAssertEqual(plan.frame.width, 336) XCTAssertEqual(plan.bottomSafeInset, 20) } - func testReflowPlanTracksSideDockVisibleWidthWithoutBottomInsetInflation() { + 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 testReflowPlanIgnoresLegacyPreferredResizableHeight() { + let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) + + let plan = ClipboardPanelController.reflowPlan( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + preferredHeight: 600 + ) + + XCTAssertEqual(plan.frame.minY, screenFrame.minY) + XCTAssertEqual(plan.frame.height, 957) + XCTAssertEqual(plan.frame.width, 336) + XCTAssertEqual(plan.bottomSafeInset, 20) + } + + 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.minX, visibleFrame.minX) XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX) - XCTAssertEqual(plan.frame.minY, visibleFrame.minY) - XCTAssertEqual(plan.frame.height, 408) + 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) + + let frames = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + side: .right + ) + + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX) + XCTAssertEqual(frames.shown.width, 336) + XCTAssertEqual(frames.hidden.minY, frames.shown.minY) + XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1) + } + + func testPanelFrameUsesConfiguredLeftSideShelf() { + let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) + let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957) + + let frames = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: visibleFrame, + side: .left + ) + + XCTAssertEqual(frames.shown.minX, visibleFrame.minX) + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY) + XCTAssertEqual(frames.shown.width, 336) + XCTAssertEqual(frames.hidden.maxX, visibleFrame.minX - 1) + XCTAssertEqual(frames.hidden.minY, frames.shown.minY) + } + + func testPanelFrameUsesFullScreenHeightWhenVisibleFrameMatchesScreen() { + let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982) + + let frames = ClipboardPanelController.panelFrames( + forScreenFrame: screenFrame, + visibleFrame: screenFrame, + side: .right + ) + + XCTAssertEqual(frames.shown.minY, screenFrame.minY) + XCTAssertEqual(frames.shown.maxY, screenFrame.maxY) + XCTAssertEqual(frames.shown.maxX, screenFrame.maxX) + XCTAssertEqual(frames.shown.width, 336) + } + func testContentBottomInsetReservesBottomDockSpace() { let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982) let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861) @@ -128,6 +375,24 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(inset, 18) } + func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() { + XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: false), .readOnly) + XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: true), .none) + } + + func testLinkPreviewFrameSitsAboveBottomShelfWhenSpaceAllows() { + let visibleFrame = NSRect(x: 0, y: 0, width: 1512, height: 982) + let shelfFrame = NSRect(x: 0, y: 0, width: 1512, height: 408) + + let frame = LinkPreviewWindowController.previewFrame(parentFrame: shelfFrame, visibleFrame: visibleFrame) + + XCTAssertGreaterThanOrEqual(frame.minY, shelfFrame.maxY + 14) + XCTAssertLessThanOrEqual(frame.maxY, visibleFrame.maxY - 24) + XCTAssertEqual(frame.width, 1088) + XCTAssertEqual(frame.height, 536) + XCTAssertEqual(frame.midX, shelfFrame.midX, accuracy: 0.5) + } + func testCommandNumberShortcutsMapToQuickPasteSlots() { XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0) XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1) @@ -181,20 +446,48 @@ final class ClipboardPanelControllerTests: XCTestCase { 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 testNavigationShortcutsRequireNoModifiers() { + 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: [])) } + 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])) + } + 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), .reveal) + 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() { @@ -203,17 +496,68 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command)) } - func testModifiedShortcutsMapToPlainTextActions() { + 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]), .copyPlainText) + 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) } 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)) + } + } } diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index c3a2a09..532759a 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -16,7 +16,7 @@ final class ClipboardPanelViewModelTests: XCTestCase { func testComputeVisibleItemsFiltersAndSortsByMode() { let settings = makeSettings() let store = makeStore(settings: settings) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService()) + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService()) let sampleItems = makeSampleItems() @@ -39,10 +39,177 @@ 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.. SettingsModel { let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.testmodel.\(UUID().uuidString)")!) settings.maxHistoryItems = 10 + settings.historyRetention = .forever settings.includeImageTextInSearch = false settings.pruneDuplicates = false return settings @@ -1296,6 +2816,17 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) } + private func makeImage(width: CGFloat, height: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: width, height: height)) + image.lockFocus() + NSColor.systemBlue.setFill() + NSRect(x: 0, y: 0, width: width, height: height).fill() + NSColor.systemOrange.setFill() + NSRect(x: width * 0.55, y: height * 0.15, width: width * 0.3, height: height * 0.7).fill() + image.unlockFocus() + return image + } + private func waitForStoreCount( _ store: ClipboardStore, count: Int, @@ -1323,6 +2854,19 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.visibleItems.count, count, file: file, line: line) } + private static let visibleItemKinds: [ClipboardItemKind] = [ + .text, + .code, + .url, + .image, + .color, + .audio, + .video, + .richText, + .pdf, + .file + ] + private func makeSampleItems() -> [ClipboardItem] { [ ClipboardItem( @@ -1416,12 +2960,16 @@ final class ClipboardPanelViewModelTests: XCTestCase { value } - private func isoDate(_ value: String) -> Date { + 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(identifier: .gregorian) + formatter.calendar = calendar formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: value)! + let date = formatter.date(from: value)! + return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date)! } } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index d107576..5c2f3b7 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -47,23 +47,785 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(view.isSearchFieldEditing) } - func testShelfChromeKeepsSearchAndCollectionsInOneCompactRow() { + 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, 164, 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, 164, 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) + } + + 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, 164, 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, 164, accuracy: 0.5) + + XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.isSearchFieldEditing) + XCTAssertEqual(fixture.view.debugSearchFieldText, "") + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) + XCTAssertFalse(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, 164, 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, 164, accuracy: 0.5) + XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) + XCTAssertFalse(fixture.view.debugSearchIconButtonIsVisible) + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum launch note"]) + } + + func testCommandFStyleSearchActionKeepsFocusInSearchField() { + let (window, view) = makePanelWithPanelView() + view.debugSuppressSearchFilterMenuPresentation() + + window.makeFirstResponder(view) + XCTAssertFalse(view.focusSearchOrShowFilters()) + XCTAssertTrue(view.isSearchFieldEditing) + XCTAssertEqual(view.debugSearchFilterMenuPresentationCount, 0) + + XCTAssertFalse(view.focusSearchOrShowFilters()) + + XCTAssertEqual(view.debugSearchFilterMenuPresentationCount, 0) + 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 testShelfChromeKeepsSearchAboveSideIconCategoryRail() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Compact shelf chrome", store: fixture.store)) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugShelfChromeRowCount, 1) - XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndCollections) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 34, accuracy: 0.5) + XCTAssertFalse(fixture.view.debugShelfChromeContainsSearchAndCollections) + XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndActions) + XCTAssertTrue(fixture.view.debugCollectionRailIsBesideCardRail) + XCTAssertTrue(fixture.view.debugSearchFieldSharesAnimatedContainerWithIcon) + XCTAssertEqual(fixture.view.debugSearchAnimationDuration, 0.14, 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.debugCollectionRailContentTopInset, 0) + XCTAssertEqual( + fixture.view.debugCollectionRailContentFrameInPanel.midY, + fixture.view.debugCollectionRailFrameInPanel.midY, + accuracy: 1 + ) + XCTAssertEqual( + fixture.view.debugCollectionRailFrameInPanel.midX, + fixture.view.debugSelectedCardFrameInPanel.minX / 2, + accuracy: 2 + ) + XCTAssertLessThan(fixture.view.debugCollectionRailFrameInPanel.midX, fixture.view.bounds.midX) + XCTAssertFalse(fixture.view.debugStatusBarIsVisible) + 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.08, 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, 300, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") + XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) + XCTAssertFalse(fixture.view.debugSearchIconButtonIsVisible) + } + + func testCollectionRailStaysOnSideAcrossSelection() { + 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 testHoveringCategoryPreviewsThenRestoresOriginalCategoryWhenLeavingRail() { + 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, "Links") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.url]) + + fixture.view.debugUnhoverCollectionChip(.links) + fixture.view.debugHoverCollectionChip(.text) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Text") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.text]) + + 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 testClickingHoveredCategoryCommitsPreviewAfterMouseLeaves() { + 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, "Images") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.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, 96) + } + + 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, ClipboardPanelLayout.vertical.title) + 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) + XCTAssertLessThanOrEqual( + fixture.view.debugCollectionRailFrameInPanel.maxX + 8, + fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 + ) + 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.36, 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) + XCTAssertEqual(fixture.view.debugCardContentBackgroundAlphas[1], 0, accuracy: 0.01) + + 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) + XCTAssertEqual(fixture.view.debugCardContentBackgroundAlphas[1], 0, accuracy: 0.01) + 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 testToolbarControlsExposeVoiceOverActionHints() { + let fixture = makePanelFixture() + + XCTAssertEqual( + fixture.view.debugSearchFieldAccessibilityHelp, + "Type to search clipboard history. Use the side category icons to filter results." + ) + XCTAssertEqual( + fixture.view.debugSearchFilterButtonAccessibilityHelp, + "" + ) + 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." + ] + ) + + fixture.view.debugToggleCompactMode() + drainMainQueue() + + XCTAssertEqual(fixture.view.debugCompactModeButtonAccessibilityValue, "") + XCTAssertEqual( + fixture.view.debugToolbarButtonAccessibilityHelps, + [ + "Clear history.", + "Open ClipBored settings. Keyboard shortcut: Command-Comma." + ] + ) + } + + func testSearchFilterMenuInsertsStructuredTokens() { + let fixture = makePanelFixture() + var image = makeItem(kind: .image, displayText: "Safari screenshot", payload: "/tmp/safari-screenshot.png", store: fixture.store) + image.sourceApp = "Safari" + image.sourceDeviceName = "MacBook Pro" + image.collectionName = "Client Work" + var text = makeTextItem("Notes follow-up", store: fixture.store) + text.sourceApp = "Notes" + fixture.store.upsert(image) + fixture.store.upsert(text) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual( + fixture.view.debugSearchFilterMenuTitles, + ["Type", "Date", "Pinned Items", "Copied From", "Device", "Pinboard"] + ) + XCTAssertEqual( + fixture.view.debugSearchFilterSubmenuTitles(named: "Type"), + ["Text", "Rich Text", "Links", "Images", "Files", "PDFs", "Audio", "Videos", "Colors", "Code"] + ) + XCTAssertTrue(fixture.view.debugSearchFilterSubmenuTitles(named: "Copied From").contains("Safari")) + XCTAssertTrue(fixture.view.debugSearchFilterSubmenuTitles(named: "Device").contains("MacBook Pro")) + XCTAssertEqual(fixture.view.debugSearchFilterSubmenuTitles(named: "Pinboard"), ["Client Work"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Images", inSubmenu: "Type") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "type:image") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["/tmp/safari-screenshot.png"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Safari", inSubmenu: "Copied From") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "type:image app:\"Safari\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["/tmp/safari-screenshot.png"]) + + fixture.view.debugSetSearchFieldText("") + fixture.view.debugPerformSearchFilterMenuItem(titled: "Client Work", inSubmenu: "Pinboard") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "pinboard:\"Client Work\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["/tmp/safari-screenshot.png"]) + } + + func testSearchFilterMenuDateTokensUseLocalCalendarDay() { + let fixture = makePanelFixture() + let lateLocalEvening = localDate("2026-07-04", hour: 23, minute: 30) + + XCTAssertEqual( + fixture.view.debugSearchFilterSubmenuTokens(named: "Date", now: lateLocalEvening), + [ + "date:2026-07-04", + "after:2026-06-28", + "before:2026-07-04" + ] + ) + } + + func testSearchFilterMenuDatePresetReplacesExistingDateRange() { + let fixture = makePanelFixture() + var today = makeTextItem("Today launch note", store: fixture.store) + today.createdAt = localDate("2026-07-04", hour: 12) + today.lastUsedAt = today.createdAt + var yesterday = makeTextItem("Yesterday launch note", store: fixture.store) + yesterday.createdAt = localDate("2026-07-03", hour: 12) + yesterday.lastUsedAt = yesterday.createdAt + fixture.store.upsert(today) + fixture.store.upsert(yesterday) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugSetSearchFieldText("launch before:2026-07-04 after:2026-06-28") + drainMainQueue() + + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Yesterday launch note"]) + + fixture.view.debugApplySearchFilterToken("date:2026-07-04") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "launch date:2026-07-04") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Today launch note"]) + } + + func testSearchFilterMenuPinnedPresetReplacesExistingPinnedState() { + let fixture = makePanelFixture() + var pinned = makeTextItem("Pinned launch note", store: fixture.store) + pinned.isPinned = true + pinned.createdAt = Date(timeIntervalSince1970: 200) + pinned.lastUsedAt = pinned.createdAt + var unpinned = makeTextItem("Unpinned launch note", store: fixture.store) + unpinned.createdAt = Date(timeIntervalSince1970: 100) + unpinned.lastUsedAt = unpinned.createdAt + fixture.store.upsert(pinned) + fixture.store.upsert(unpinned) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugSetSearchFieldText("launch pin:off") + drainMainQueue() + + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Unpinned launch note"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Pinned Items") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "launch pinned:on") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Pinned launch note"]) + } + + func testSearchFilterMenuTypePresetReplacesExistingTypeFilter() { + let fixture = makePanelFixture() + var text = makeTextItem("Launch note", store: fixture.store) + text.createdAt = Date(timeIntervalSince1970: 100) + text.lastUsedAt = text.createdAt + var image = makeItem(kind: .image, displayText: "Launch screenshot", payload: "/tmp/launch.png", store: fixture.store) + image.createdAt = Date(timeIntervalSince1970: 200) + image.lastUsedAt = image.createdAt + fixture.store.upsert(text) + fixture.store.upsert(image) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugSetSearchFieldText("launch kind:image") + drainMainQueue() + + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["/tmp/launch.png"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Text", inSubmenu: "Type") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "launch type:text") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Launch note"]) + } + + func testSearchFilterMenuTreatsExistingFiltersAsWholeTokens() { + let fixture = makePanelFixture() + var item = makeTextItem("Safari research clip", store: fixture.store) + item.sourceApp = "Safari" + fixture.store.upsert(item) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugSetSearchFieldText("app:SafariResearch") + drainMainQueue() + + XCTAssertTrue(fixture.viewModel.visibleItems.isEmpty) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Safari", inSubmenu: "Copied From") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "app:SafariResearch app:\"Safari\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Safari research clip"]) + } + + func testSearchFilterMenuUsesExactSourceAppValues() { + let fixture = makePanelFixture() + var safari = makeTextItem("Safari clip", store: fixture.store) + safari.sourceApp = "Safari" + var preview = makeTextItem("Technology preview clip", store: fixture.store) + preview.sourceApp = "Safari Technology Preview" + fixture.store.upsert(safari) + fixture.store.upsert(preview) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Safari", inSubmenu: "Copied From") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "app:\"Safari\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Safari clip"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Safari", inSubmenu: "Copied From") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "app:\"Safari\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Safari clip"]) + } + + func testSearchFilterMenuUpgradesMatchingFuzzySourceFilterToExactValue() { + let fixture = makePanelFixture() + var safari = makeTextItem("Safari clip", store: fixture.store) + safari.sourceApp = "Safari" + var preview = makeTextItem("Technology preview clip", store: fixture.store) + preview.sourceApp = "Safari Technology Preview" + fixture.store.upsert(safari) + fixture.store.upsert(preview) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugSetSearchFieldText("app:Safari") + drainMainQueue() + + XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.payload)), ["Safari clip", "Technology preview clip"]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Safari", inSubmenu: "Copied From") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "app:\"Safari\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Safari clip"]) + } + + func testSearchFilterMenuPreservesQuotesInStructuredValues() { + let fixture = makePanelFixture() + var item = makeTextItem("Quoted pinboard clip", store: fixture.store) + item.collectionName = "Client \"VIP\"" + fixture.store.upsert(item) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSearchFilterSubmenuTitles(named: "Pinboard"), ["Client \"VIP\""]) + + fixture.view.debugPerformSearchFilterMenuItem(titled: "Client \"VIP\"", inSubmenu: "Pinboard") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSearchFieldText, "pinboard:\"Client \\\"VIP\\\"\"") + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quoted pinboard clip"]) + } + + func testSearchFilterMenuPreservesInvalidStructuredLookingText() { + let fixture = makePanelFixture() + + fixture.view.debugSetSearchFieldText("launch before:lunch kind:maybe pinned:maybe") + fixture.view.debugApplySearchFilterToken("date:2026-07-04") + drainMainQueue() + + XCTAssertEqual( + fixture.view.debugSearchFieldText, + "launch before:lunch kind:maybe pinned:maybe date:2026-07-04" + ) + + fixture.view.debugApplySearchFilterToken("type:text") + drainMainQueue() + + XCTAssertEqual( + fixture.view.debugSearchFieldText, + "launch before:lunch kind:maybe pinned:maybe date:2026-07-04 type:text" + ) + + fixture.view.debugApplySearchFilterToken("pinned:on") + drainMainQueue() + + XCTAssertEqual( + fixture.view.debugSearchFieldText, + "launch before:lunch kind:maybe pinned:maybe date:2026-07-04 type:text pinned:on" + ) + } + + func testActiveVerticalCardAvoidsRectangularShadowChrome() { + 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 + ) + XCTAssertEqual(fixture.view.debugFirstCardShadowOpacity, 0, accuracy: 0.01) + XCTAssertEqual(fixture.view.debugFirstCardShadowRadius, 0, accuracy: 0.5) + 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() { @@ -79,7 +841,10 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugResultCountText, "1 clip") XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack) XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292) - XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244) + XCTAssertGreaterThanOrEqual( + fixture.view.debugDocumentViewFrame.height, + fixture.view.debugCardRailVisibleRect.height - 1 + ) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) } @@ -92,6 +857,9 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -102,11 +870,13 @@ final class ClipboardPanelViewTests: XCTestCase { 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 testCompactCardsFitTwoItemsOnNarrowDockShelf() { + func testCompactRowsFitSideShelf() { let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + 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() @@ -115,14 +885,135 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardDensity, "compact") XCTAssertEqual(fixture.view.debugVisibleCardCount, 2) XCTAssertEqual(fixture.view.debugCardSizes.count, 2) - XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 220, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row", "vertical-row"]) + XCTAssertLessThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 280) + 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 testCompactRowsShowCharacterMetricWithoutEllipsisOrOverflow() throws { + 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"]) + let metricIndex = try XCTUnwrap(fixture.view.debugCardCompactMetricTexts.firstIndex(of: "1.2k chars")) + XCTAssertEqual(fixture.view.debugCardPresentations[metricIndex], "vertical-row") + XCTAssertFalse(fixture.view.debugCardCompactMetricHiddenStates[metricIndex]) + assertCompactMetricFitsWithoutEllipsisOrOverflow(in: fixture, at: metricIndex) + } + + func testCompactRowsShowFileSizeMetricWhenBackingFileExists() 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"]) + let metricIndex = try XCTUnwrap(fixture.view.debugCardCompactMetricTexts.firstIndex(of: "2 KB")) + XCTAssertEqual(fixture.view.debugCardPresentations[metricIndex], "vertical-row") + XCTAssertFalse(fixture.view.debugCardCompactMetricHiddenStates[metricIndex]) + assertCompactMetricFitsWithoutEllipsisOrOverflow(in: fixture, at: metricIndex) + } + + func testCompactModeToggleIsRemovedAndSideShelfStaysCompact() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Wide shelf compact mode", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCardDensity, "compact") + XCTAssertEqual(fixture.view.debugCompactModeButtonAccessibilityValue, "") + XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row"]) + XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5) + + fixture.view.debugToggleCompactMode() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertFalse(fixture.settings.compactMode) + XCTAssertEqual(fixture.view.debugStatusText, "Compact Mode was removed") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + XCTAssertEqual(fixture.view.debugCardDensity, "compact") + XCTAssertEqual(fixture.view.debugCompactModeButtonAccessibilityValue, "") + XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row"]) + XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5) + } + + 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, ClipboardPanelLayout.vertical.title) + 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 testVerticalShelfSwitchesFromWidePanelWithoutKeepingWideCategoryRail() { + 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, ClipboardPanelLayout.vertical.title) + XCTAssertEqual(fixture.view.bounds.width, 336, accuracy: 1) + XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) + XCTAssertLessThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 280) + } + func testCardsShowQuickPasteNumberBadgesForFirstNineItems() { let fixture = makePanelFixture() for index in 0..<10 { @@ -172,6 +1063,35 @@ final class ClipboardPanelViewTests: XCTestCase { 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)) @@ -194,26 +1114,99 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugStatusTone, "warning") } - func testPanelShellRendersAsSquareDockedSurface() { + func testValidationStatusMessagesUseWarningTone() { + let fixture = makePanelFixture() + + fixture.viewModel.addVisibleItemsToStack() + drainMainQueue() + + XCTAssertEqual(fixture.view.debugStatusText, "No visible clips to stack") + XCTAssertEqual(fixture.view.debugStatusTone, "warning") + + fixture.viewModel.createTextClip(" ") + drainMainQueue() + + XCTAssertEqual(fixture.view.debugStatusText, "Text clip cannot be empty") + XCTAssertEqual(fixture.view.debugStatusTone, "warning") + + fixture.store.upsert(makeTextItem("Stack warning note", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.viewModel.addSelectedItemsToStack() + drainMainQueue() + + XCTAssertEqual(fixture.view.debugStatusText, "Added 1 selected clip to Stack") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + + fixture.viewModel.addSelectedItemsToStack() + drainMainQueue() + + XCTAssertEqual(fixture.view.debugStatusText, "Selected clips are already in Stack") + XCTAssertEqual(fixture.view.debugStatusTone, "warning") + } + + func testPanelShellUsesTransparentLiquidGlassSurface() { let (_, view) = makePanelWithPanelView() drainMainQueue() 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 width = rep.pixelsWide - let height = rep.pixelsHigh - func alphaAt(_ x: Int, _ y: Int) -> CGFloat { - rep.colorAt(x: x, y: y)?.alphaComponent ?? 0 + XCTAssertEqual(view.debugPanelMaterial, .hudWindow) + XCTAssertEqual(view.debugPanelCornerRadius, 22) + 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.") } - XCTAssertEqual(view.debugPanelCornerRadius, 0) - XCTAssertGreaterThan(alphaAt(8, 8), 0.9) - XCTAssertGreaterThan(alphaAt(width - 9, 8), 0.9) - XCTAssertGreaterThan(alphaAt(8, height - 9), 0.9) - XCTAssertGreaterThan(alphaAt(width - 9, height - 9), 0.9) + 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) + 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() { @@ -241,47 +1234,213 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardAccessibilityLabels.first, "Text: New clip during open") } - func testCollectionRailUsesPasteStyleLabelsAndTracksSelection() { + func testFocusedCardKeepsKeyboardFocusWhenVisibleItemsReloadAboveIt() { let fixture = makePanelFixture() - - XCTAssertEqual( - fixture.view.debugCollectionTitles, - ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Videos", "Files", "Pinned", "Code"] - ) - XCTAssertEqual( - fixture.view.debugCollectionLeadingSymbols, - ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "film", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"] - ) - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - - fixture.viewModel.sortMode = .links + 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() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") + 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 testCollectionRailChipsAreKeyboardFocusableAndVoiceOverDescriptive() { + func testHoveringAnotherCardBecomesLatestSingleSelectionForArrowNavigation() { + 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.viewModel.selectAllVisibleItems() + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 1, 2]) + + fixture.view.debugHoverCard(at: 2) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Oldest hover note") + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [2]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [2]) + 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, "mouse") + XCTAssertEqual(fixture.view.debugCardVisibleActionLabels(at: 2), []) + + fixture.view.debugPressFocusedResponderKeyCode(123) + 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, "Oldest hover note") + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [2]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [2]) + XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) + XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "mouse") + } + + func testCollectionRailUsesIconOnlySideItemsAndTracksSelection() { let fixture = makePanelFixture() drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugCollectionChipAcceptsFirstResponder, Array(repeating: true, count: ClipboardSortMode.allCases.count)) - XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: true, count: ClipboardSortMode.allCases.count)) - XCTAssertEqual(fixture.view.debugCollectionChipAccessibilityLabels.first, "Clipboard, selected, 0 clips") + 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") - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) - fixture.view.debugPressFocusedResponderWithSpace() + 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]) + XCTAssertEqual(fixture.view.debugCategoryMenuTitles, []) + + fixture.view.debugMouseDownCollectionChip(.links) drainMainQueue() XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - XCTAssertTrue(fixture.view.debugCollectionChipAccessibilityLabels.contains("Links, selected, 0 clips")) } - func testCollectionRailChipsSupportShelfNavigationKeys() { + 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.debugCommandMouseDownCollectionChip(.text) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Text category note"]) + + 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 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, + Array(repeating: true, count: fixture.view.debugCollectionTitles.count) + ) + XCTAssertEqual(fixture.view.debugCollectionChipAccessibilityLabels.first, "Clipboard, selected, 1 clip") + XCTAssertEqual( + fixture.view.debugCollectionChipAccessibilityHelps.first, + "Press Return or Space to show Clipboard. Use Up/Down to move between categories, Left/Right to move between clips, and Home/End to move between categories." + ) + + XCTAssertEqual(fixture.view.debugCategoryMenuTitles, []) + 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. Use Up/Down to move between categories, Left/Right to move between clips, and Home/End to move between categories." + ) + ) + + fixture.view.debugMouseDownCollectionChip(.links) + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") + } + + func testCollectionRailChipArrowsMoveCardSelectionInsteadOfChangingCategories() { 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() @@ -290,27 +1449,31 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(fixture.viewModel.selectedIndex, 0) + fixture.view.debugPressFocusedResponderKeyCode(124) drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Images") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Images"]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(fixture.viewModel.selectedIndex, 1) + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, []) fixture.view.debugPressFocusedResponderKeyCode(123) drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Links"]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(fixture.viewModel.selectedIndex, 0) + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) fixture.view.debugPressFocusedResponderKeyCode(119) drainMainQueue() XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Stack"]) - fixture.view.debugPressFocusedResponderKeyCode(123) - drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Client Work") - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Client Work"]) - fixture.view.debugPressFocusedResponderKeyCode(115) drainMainQueue() XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") @@ -324,7 +1487,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) fixture.view.debugTypeFocusedResponder("q", keyCode: 12) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -334,6 +1497,26 @@ final class ClipboardPanelViewTests: XCTestCase { 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)) @@ -345,7 +1528,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Quantum reference"]) - XCTAssertTrue(fixture.view.debugFocusCollectionChip(.text)) + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) drainMainQueue() @@ -372,11 +1555,48 @@ final class ClipboardPanelViewTests: XCTestCase { 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.view.debugStatusText, "Cleared 1 clip") + + fixture.view.debugPerformClearHistoryMenuItem(titled: "All Time") + drainMainQueue() + + XCTAssertTrue(fixture.viewModel.visibleItems.isEmpty) + XCTAssertEqual(fixture.view.debugStatusText, "Cleared 1 clip") + } + func testCollectionFilteredCardsUseStoredCollectionHeaderColor() { let fixture = makePanelFixture() fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8") @@ -394,7 +1614,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") } - func testCardHeaderUsesReadableRelativeAgeText() { + func testCardHeaderUsesPasteStyleRelativeAgeText() { let fixture = makePanelFixture() var item = makeTextItem("Readable age", store: fixture.store) item.createdAt = Date().addingTimeInterval(-3 * 60) @@ -403,6 +1623,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() + XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text") XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "3 minutes ago") fixture.viewModel.createCollection(named: "Age Stack", colorHex: "#FF3B30", selectAfterCreate: false) @@ -420,7 +1641,14 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugCustomCollectionMenuTitles(named: "Research Stack"), ["Edit Collection...", "-", "Delete Collection"]) + 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. Use Up/Down to move between categories, Left/Right to move between clips, and Home/End to move between categories. Open the context menu to edit, export, or delete this Pinboard." + ) } func testCollectionChipManagementRenamesAndDeletesCollections() { @@ -432,12 +1660,16 @@ final class ClipboardPanelViewTests: XCTestCase { 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, "Product Research") XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF") @@ -446,6 +1678,8 @@ final class ClipboardPanelViewTests: XCTestCase { 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), []) } @@ -456,14 +1690,18 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210) + 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.debugFirstCardHeaderBadgeFrame.width, 56, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.height, 56, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxX, 320, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 244, accuracy: 0.5) + 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() @@ -471,15 +1709,73 @@ final class ClipboardPanelViewTests: XCTestCase { 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, ["Paste", "Copy", "Paste Plain Text", "Collect", "Preview", "Open", "Reveal", "More"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 238) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 0) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeIsHidden) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.width, 56, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.height, 56, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxX, 320, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 244, accuracy: 0.5) + 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() { @@ -490,14 +1786,16 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugCardDensity, "compact") - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Paste Plain Text", "Collect", "Preview", "Open", "More"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 196) - XCTAssertLessThanOrEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 197) + 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) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.width, 50, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.height, 50, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxX, 264, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 220, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5) + 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"] @@ -517,9 +1815,9 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Older text card") XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugCardBorderWidths[1], 2) + 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. Press Space for Quick Look.") + 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() @@ -527,6 +1825,161 @@ final class ClipboardPanelViewTests: XCTestCase { 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.view.debugStatusText, "Deleted 2 clips") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + 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.view.debugStatusText, "Restored 2 clips") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + 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)) @@ -561,6 +2014,26 @@ final class ClipboardPanelViewTests: XCTestCase { 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)) @@ -584,6 +2057,29 @@ final class ClipboardPanelViewTests: XCTestCase { 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)) @@ -592,7 +2088,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) drainMainQueue() - XCTAssertEqual(fixture.view.debugCardAccessibilityHelps.first, "Press Return to paste. Press Space for Quick Look.") + 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() @@ -603,7 +2099,7 @@ final class ClipboardPanelViewTests: XCTestCase { func testFocusedCardsSupportShelfNavigationKeys() { let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + 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)) @@ -647,6 +2143,18 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() XCTAssertEqual(fixture.viewModel.selectedIndex, 0) XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) + + fixture.view.debugPressFocusedResponderKeyCode(124, 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.debugResultCountText, "8 selected of 8 clips") + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) } func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() { @@ -655,9 +2163,46 @@ final class ClipboardPanelViewTests: XCTestCase { 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.70 + ) + } + + 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() { @@ -696,8 +2241,11 @@ final class ClipboardPanelViewTests: XCTestCase { 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: false, count: ClipboardSortMode.allCases.count)) + XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: true, count: 11)) + XCTAssertEqual(fixture.view.debugCategoryMenuTitles, []) } func testCollectionRailShowsAssignedCollections() { @@ -716,7 +2264,9 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Useful Links", "Important Notes", "Client Work"]) XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [1, 1, 1]) - XCTAssertEqual(fixture.view.debugCustomCollectionCountLabelHiddenStates, [false, false, false]) + 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() @@ -773,32 +2323,35 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleWidth, 0) + XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) + XCTAssertFalse(fixture.view.debugCollectionRailHasHorizontalScroller) + XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 300) XCTAssertGreaterThan( - fixture.view.debugCollectionRailDocumentWidth, - fixture.view.debugCollectionRailVisibleWidth + 1 + fixture.view.debugCollectionRailVisibleRect.height, + 1 ) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work")) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) - XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, true]) + XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeWidth, 44, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) fixture.view.debugScrollCollectionRailVertically(deltaY: -220) fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minX, 0) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [true, true]) + XCTAssertGreaterThanOrEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0) + XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000) fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minX, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, true]) + XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) } func testSelectionScrollsCardRailToKeepSelectedCardVisible() { let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + 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)) @@ -809,7 +2362,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.selectFirstItem() drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minX, 1) + XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minY, 1) fixture.viewModel.selectItem(at: fixture.viewModel.visibleItems.count - 1) drainMainQueue() @@ -817,20 +2370,20 @@ final class ClipboardPanelViewTests: XCTestCase { let visibleRect = fixture.view.debugCardRailVisibleRect let selectedFrame = fixture.view.debugSelectedCardFrameInDocument - XCTAssertGreaterThan(visibleRect.minX, 0) - XCTAssertLessThanOrEqual(selectedFrame.minX, visibleRect.maxX) - XCTAssertGreaterThanOrEqual(visibleRect.maxX + 1, selectedFrame.maxX) + 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.minX, 1) + XCTAssertLessThanOrEqual(fixture.view.debugCardRailVisibleRect.minY, 1) } - func testVerticalWheelPansHorizontalCardRailAndClamps() { + func testVerticalWheelPansSideCardListAndClamps() { let fixture = makePanelFixture() - fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true) + 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)) @@ -842,30 +2395,31 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertGreaterThan( - fixture.view.debugCardRailDocumentWidth, - fixture.view.debugCardRailVisibleRect.width + 1 + fixture.view.debugCardRailDocumentHeight, + fixture.view.debugCardRailVisibleRect.height + 1 ) - XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, true]) + 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: -240) + fixture.view.debugScrollCardRailVertically(deltaY: -60) fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertGreaterThan(fixture.view.debugCardRailVisibleRect.minX, 0) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [true, true]) + 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.debugCardRailDocumentWidth - fixture.view.debugCardRailVisibleRect.width - XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minX, maxOffset, accuracy: 1) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [true, false]) + 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.minX, 0, accuracy: 1) - XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, true]) + XCTAssertEqual(fixture.view.debugCardRailVisibleRect.minY, 0, accuracy: 1) + XCTAssertEqual(fixture.view.debugCardRailOverflowFadeVisibility, [false, false]) } func testFilteredEmptyStateNamesCurrentCollection() { @@ -880,6 +2434,24 @@ final class ClipboardPanelViewTests: XCTestCase { 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.") + XCTAssertEqual(fixture.view.debugResultCountText, "0 clips matching") + XCTAssertEqual(fixture.view.debugStatusText, "No clips match this search") + XCTAssertEqual(fixture.view.debugStatusTone, "neutral") + } + func testPinnedEmptyStatePointsToPinAction() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store)) @@ -916,6 +2488,27 @@ final class ClipboardPanelViewTests: XCTestCase { ) } + func testCaptureRuleActionsUseWarningStatusTone() { + 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.view.debugStatusText, "Ignored Ghostty for future captures") + XCTAssertEqual(fixture.view.debugStatusTone, "warning") + + fixture.view.debugPerformFirstCardCaptureRuleMenuItem(titled: "Ignore Text Items") + drainMainQueue() + + XCTAssertTrue(fixture.settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) + XCTAssertEqual(fixture.view.debugStatusText, "Ignored Text items for future captures") + XCTAssertEqual(fixture.view.debugStatusTone, "warning") + } + func testFilteredCardsExposeShowInClipboardContextMenuAction() { let fixture = makePanelFixture() var release = makeTextItem("Release needle", store: fixture.store) @@ -946,6 +2539,68 @@ final class ClipboardPanelViewTests: XCTestCase { 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, 164, 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, 164, 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)) @@ -962,6 +2617,62 @@ final class ClipboardPanelViewTests: XCTestCase { ) } + 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"]) + XCTAssertEqual(fixture.view.debugResultCountText, "2 selected of 3 clips") + 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.view.debugStatusText, "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)) @@ -976,7 +2687,10 @@ final class ClipboardPanelViewTests: XCTestCase { 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, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) + XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) + drainMainQueue() + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"]) } @@ -990,17 +2704,25 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Add to Stack", "Add to Stack"]) - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, true]) + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) XCTAssertFalse(fixture.view.debugFirstCardVisibleActionLabels.contains("Add to Stack")) - XCTAssertGreaterThan(fixture.view.debugFirstCardStackCornerFrame.maxX, 290) + let cardIdentifiersBeforeStackToggle = fixture.view.debugCardObjectIdentifiers - fixture.view.debugPressFirstCardStackCornerButton() + XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) + XCTAssertGreaterThan(fixture.view.debugFirstCardStackCornerFrame.maxX, 220) + + fixture.viewModel.toggleSelectedStackMembership() drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugStatusText, "Added to Stack") + XCTAssertEqual(fixture.view.debugCardObjectIdentifiers, cardIdentifiersBeforeStackToggle) XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack", "Add to Stack"]) - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, true]) + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) XCTAssertTrue(fixture.view.debugStackChipIsVisible) XCTAssertEqual(fixture.view.debugStackChipCount, 1) @@ -1008,7 +2730,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, false]) + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) } func testStackChipAppearsFiltersAndClearsWithStack() { @@ -1030,6 +2752,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugStackChipIsVisible) XCTAssertEqual(fixture.view.debugStackChipCount, 2) XCTAssertFalse(fixture.view.debugStackChipIsSelected) + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Clipboard"]) fixture.view.debugPressStackChip() drainMainQueue() @@ -1037,6 +2760,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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() @@ -1047,6 +2771,42 @@ final class ClipboardPanelViewTests: XCTestCase { 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.view.debugStatusText, "Stack capture is on") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + + 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.view.debugStatusText, "Stack capture is off") + XCTAssertEqual(fixture.view.debugStatusTone, "action") + } + func testStackChipMenuAddsVisibleShelfToQueue() { let fixture = makePanelFixture() var older = makeTextItem("Older batch stack item", store: fixture.store) @@ -1073,7 +2833,11 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugStackChipCount, 1) XCTAssertEqual( fixture.view.debugStackChipMenuTitles, - ["Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack"] + ["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. Use Up/Down to move between categories, Left/Right to move between clips, and Home/End to move between categories. Open the context menu for Stack capture and Stack paste actions." ) fixture.view.debugAddVisibleClipsToStackFromStackChip() @@ -1146,6 +2910,11 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1166,6 +2935,27 @@ final class ClipboardPanelViewTests: XCTestCase { 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() @@ -1192,6 +2982,39 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1226,6 +3049,28 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1317,6 +3162,9 @@ final class ClipboardPanelViewTests: XCTestCase { ["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, @@ -1346,6 +3194,8 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1365,6 +3215,30 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1384,6 +3258,31 @@ final class ClipboardPanelViewTests: XCTestCase { 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 { @@ -1417,6 +3316,8 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1436,6 +3337,31 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -1458,7 +3384,60 @@ final class ClipboardPanelViewTests: XCTestCase { ["Swift Snippet|func greet(name: String) -> String { return \"Hi \\(name)\" }|Swift"] ) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["code-preview"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"]) + 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.debugCollectionRailContentTopInset, 0, file: file, line: line) + XCTAssertEqual( + fixture.view.debugCollectionRailContentFrameInPanel.midY, + fixture.view.debugCollectionRailFrameInPanel.midY, + accuracy: 1, + file: file, + line: line + ) + XCTAssertEqual(fixture.view.debugCollectionStackOrientation, .vertical, file: file, line: line) + XCTAssertLessThan( + fixture.view.debugCollectionRailFrameInPanel.midX, + fixture.view.bounds.midX, + file: file, + line: line + ) + } + + private func assertCompactMetricFitsWithoutEllipsisOrOverflow( + in fixture: PanelFixture, + at index: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + let metricTexts = fixture.view.debugCardCompactMetricTexts + let metricFrames = fixture.view.debugCardCompactMetricFramesInPanel + let fittingWidths = fixture.view.debugCardCompactMetricFittingWidths + let headerFrames = fixture.view.debugCardHeaderFramesInPanel + guard metricTexts.indices.contains(index), + metricFrames.indices.contains(index), + fittingWidths.indices.contains(index), + headerFrames.indices.contains(index) else { + XCTFail("Expected compact metric debug data at index \(index)", file: file, line: line) + return + } + + let metricText = metricTexts[index] + let metricFrame = metricFrames[index] + let fittingWidth = fittingWidths[index] + let headerFrame = headerFrames[index] + + XCTAssertFalse(metricText.contains("…"), file: file, line: line) + XCTAssertFalse(metricText.contains("..."), file: file, line: line) + XCTAssertGreaterThan(metricFrame.width, 0, file: file, line: line) + XCTAssertGreaterThanOrEqual(metricFrame.width + 0.5, fittingWidth, file: file, line: line) + XCTAssertGreaterThanOrEqual(metricFrame.minX, headerFrame.minX - 0.5, file: file, line: line) + XCTAssertLessThanOrEqual(metricFrame.maxX, headerFrame.maxX + 0.5, file: file, line: line) } private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) { @@ -1468,7 +3447,10 @@ final class ClipboardPanelViewTests: XCTestCase { private func makePanelFixture(cacheService: ClipboardCacheService? = nil) -> PanelFixture { let settings = makeSettings() - let cacheService = cacheService ?? ClipboardCacheService() + 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() @@ -1502,12 +3484,18 @@ final class ClipboardPanelViewTests: XCTestCase { 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()) + ClipboardStore( + settings: settings, + cacheService: cacheService, + baseURL: makeTempDirectory(), + encryptionService: ClipboardEncryptionService(keyProvider: { nil }) + ) } private func makeTempDirectory() -> URL { @@ -1582,6 +3570,33 @@ final class ClipboardPanelViewTests: XCTestCase { 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/ClipboardStoreTests.swift b/tests/clipboredtests/ClipboardStoreTests.swift index cb28ecc..afb9134 100644 --- a/tests/clipboredtests/ClipboardStoreTests.swift +++ b/tests/clipboredtests/ClipboardStoreTests.swift @@ -237,10 +237,12 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertEqual(store.items.first?.id, itemID) XCTAssertEqual(store.items.first?.payload, "legacy payload") XCTAssertEqual(store.items.first?.useCount, 3) + XCTAssertEqual(store.items.first?.sourceDeviceName, ClipboardItem.localDeviceName) let restored = makeStore(settings: settings) restored.flushPersistenceForTesting() XCTAssertEqual(restored.items.first?.payload, "legacy payload") + XCTAssertEqual(restored.items.first?.sourceDeviceName, ClipboardItem.localDeviceName) } func testPinnedItemsSurviveNormalHistoryPrune() { @@ -266,6 +268,114 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned })) } + func testCollectionItemsSurviveNormalHistoryPrune() { + let settings = makeSettings(maxHistory: 50) + let store = makeStore(settings: settings) + let start = Date() + var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500)) + saved.collectionName = "Client Work" + + store.upsert(saved) + for index in 0..<60 { + store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index)))) + } + store.flushPersistenceForTesting() + + XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" })) + XCTAssertEqual(store.items.filter { !$0.isPinned && $0.collectionName == nil }.count, 50) + XCTAssertEqual(store.items.count, 51) + + let restored = makeStore(settings: settings) + restored.flushPersistenceForTesting() + XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" })) + } + + func testRemovingCollectionMakesOverflowItemEligibleForHistoryPrune() { + let settings = makeSettings(maxHistory: 50) + let store = makeStore(settings: settings) + let start = Date() + var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500)) + saved.collectionName = "Client Work" + + store.upsert(saved) + for index in 0..<60 { + store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index)))) + } + let savedID = try! XCTUnwrap(store.items.first(where: { $0.payload == "collection-old" })?.id) + + store.setCollection(savedID, name: nil) + store.flushPersistenceForTesting() + + XCTAssertFalse(store.items.contains(where: { $0.payload == "collection-old" })) + XCTAssertEqual(store.items.count, 50) + + let restored = makeStore(settings: settings) + restored.flushPersistenceForTesting() + XCTAssertFalse(restored.items.contains(where: { $0.payload == "collection-old" })) + } + + func testHistoryRetentionPrunesExpiredUnpinnedItems() { + let settings = makeSettings(maxHistory: 50) + settings.historyRetention = .oneDay + let store = makeStore(settings: settings) + let now = Date() + + store.upsert(makeItem("old", displayText: "Old", created: now.addingTimeInterval(-2 * 24 * 60 * 60))) + store.upsert(makeItem("recent", displayText: "Recent", created: now.addingTimeInterval(-60))) + store.flushPersistenceForTesting() + + XCTAssertEqual(store.items.map(\.payload), ["recent"]) + XCTAssertFalse(store.items.contains(where: { $0.payload == "old" })) + + let restored = makeStore(settings: settings) + restored.flushPersistenceForTesting() + XCTAssertEqual(restored.items.map(\.payload), ["recent"]) + } + + func testHistoryRetentionKeepsPinnedAndCollectionItems() { + let settings = makeSettings(maxHistory: 50) + settings.historyRetention = .oneDay + let store = makeStore(settings: settings) + let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60) + var pinned = makeItem("pinned-old", displayText: "Pinned", created: oldDate) + pinned.isPinned = true + var saved = makeItem("collection-old", displayText: "Saved", created: oldDate) + saved.collectionName = "Client Work" + + store.upsert(makeItem("plain-old", displayText: "Plain", created: oldDate)) + store.upsert(pinned) + store.upsert(saved) + store.flushPersistenceForTesting() + + XCTAssertFalse(store.items.contains(where: { $0.payload == "plain-old" })) + XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned })) + XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" })) + + let restored = makeStore(settings: settings) + restored.flushPersistenceForTesting() + XCTAssertFalse(restored.items.contains(where: { $0.payload == "plain-old" })) + XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned })) + XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" })) + } + + func testDuplicateCopyRefreshesRetentionAge() { + let settings = makeSettings(maxHistory: 50) + let store = makeStore(settings: settings) + let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + store.upsert(makeItem("same", displayText: "Original", created: oldDate)) + let originalCreatedAt = try! XCTUnwrap(store.items.first?.createdAt) + store.upsert(makeItem("same", displayText: "Copied again", created: oldDate)) + + XCTAssertGreaterThan(try! XCTUnwrap(store.items.first?.createdAt), originalCreatedAt) + + settings.historyRetention = .oneDay + store.normalizeHistoryLength() + + XCTAssertEqual(store.items.map(\.payload), ["same"]) + XCTAssertEqual(store.items.first?.displayText, "Copied again") + } + func testStorageFilesUsePrivatePermissions() throws { let settings = makeSettings(maxHistory: 50) let store = makeStore(settings: settings) @@ -360,7 +470,8 @@ final class ClipboardStoreTests: XCTestCase { isPinned: false, sourceAppBundleId: "com.example.secret.\(UUID().uuidString)", ocrText: "OCR secret \(UUID().uuidString)", - collectionName: "Collection secret \(UUID().uuidString)" + collectionName: "Collection secret \(UUID().uuidString)", + sourceDeviceName: "Device secret \(UUID().uuidString)" ) store.upsert(item) @@ -375,6 +486,7 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!)) XCTAssertFalse(rawDatabaseText.contains(item.ocrText!)) XCTAssertFalse(rawDatabaseText.contains(item.collectionName!)) + XCTAssertFalse(rawDatabaseText.contains(item.sourceDeviceName!)) let restored = makeStore(settings: settings, encryptionService: encryptionService) restored.flushPersistenceForTesting() @@ -386,6 +498,7 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId) XCTAssertEqual(restored.items.first?.ocrText, item.ocrText) XCTAssertEqual(restored.items.first?.collectionName, item.collectionName) + XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName) } func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws { @@ -406,7 +519,8 @@ final class ClipboardStoreTests: XCTestCase { isPinned: false, sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)", ocrText: "Legacy OCR \(UUID().uuidString)", - collectionName: "Legacy collection \(UUID().uuidString)" + collectionName: "Legacy collection \(UUID().uuidString)", + sourceDeviceName: "Legacy device \(UUID().uuidString)" ) plaintextStore.upsert(item) @@ -424,6 +538,7 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId) XCTAssertEqual(restored.items.first?.ocrText, item.ocrText) XCTAssertEqual(restored.items.first?.collectionName, item.collectionName) + XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName) let migratedDatabaseText = try databaseText() XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker)) @@ -434,6 +549,7 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!)) XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!)) XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!)) + XCTAssertFalse(migratedDatabaseText.contains(item.sourceDeviceName!)) } func testDuplicatePDFReplacementRemovesOldAttachment() throws { @@ -454,6 +570,47 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: newPath)) } + func testRemoveCanDeferManagedCachePurgeForUndoRestore() throws { + let settings = makeSettings(maxHistory: 50) + let store = makeStore(settings: settings) + let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID())) + let item = makeImageItem( + fullPath: cachedImage.full, + thumbPath: cachedImage.thumb, + hash: "undo-image", + ocrText: "undo searchable text", + created: Date(timeIntervalSince1970: 10) + ) + store.upsert(item) + store.flushPersistenceForTesting() + + let removal = try XCTUnwrap(store.remove(item.id, purgeManagedCache: false)) + store.flushPersistenceForTesting() + cacheService.flushForTesting() + + XCTAssertTrue(store.items.isEmpty) + XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb)) + + store.restore([removal]) + store.flushPersistenceForTesting() + + XCTAssertEqual(store.items.map(\.id), [item.id]) + XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb)) + + let restored = makeStore(settings: settings) + restored.flushPersistenceForTesting() + XCTAssertEqual(restored.items.map(\.id), [item.id]) + + _ = store.remove(item.id) + store.flushPersistenceForTesting() + cacheService.flushForTesting() + + XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.full)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.thumb)) + } + func testDuplicateReplacementClearsStaleImageSearchMetadata() throws { let settings = makeSettings(maxHistory: 50) settings.keepFirstImage = false @@ -501,9 +658,204 @@ final class ClipboardStoreTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb)) } + func testArchiveExportImportMovesHistoryAndManagedAttachmentsToNewStorage() throws { + let settings = makeSettings(maxHistory: 50) + let store = makeStore(settings: settings) + let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID())) + let sourceFullImageData = try XCTUnwrap(cacheService.data(for: cachedImage.full)) + let sourceThumbData = try XCTUnwrap(cacheService.data(for: cachedImage.thumb)) + let sourcePDFData = Data("%PDF archive payload".utf8) + let sourcePDFPath = try XCTUnwrap(cacheService.cachePDF(sourcePDFData, id: UUID())) + let created = Date(timeIntervalSince1970: 300) + + var note = makeItem("archive note", displayText: "Archive Note", created: created) + note.collectionName = "Read Later" + note.customTitle = "Migration Note" + note.sourceDeviceName = "Studio Mac" + var image = makeImageItem( + fullPath: cachedImage.full, + thumbPath: cachedImage.thumb, + hash: "archive-image", + ocrText: "blue archive image", + created: created.addingTimeInterval(1) + ) + image.isPinned = true + image.collectionName = "Client Work" + image.customTitle = "Launch Screenshot" + image.sourceDeviceName = "MacBook Pro" + var pdf = makePDFItem( + path: sourcePDFPath, + hash: "archive-pdf", + created: created.addingTimeInterval(2) + ) + pdf.collectionName = "Research" + pdf.sourceDeviceName = "Design Mac" + + store.upsert(note) + store.upsert(image) + store.upsert(pdf) + store.flushPersistenceForTesting() + + let archiveURL = baseURL.appendingPathComponent("backup.clipboredarchive") + let exportSummary = try store.exportArchive(to: archiveURL) + + XCTAssertEqual(exportSummary.itemCount, 3) + XCTAssertEqual(exportSummary.sidecarCount, 3) + XCTAssertEqual(try posixPermissions(archiveURL), 0o600) + + let destinationBaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("clipboredtests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: destinationBaseURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: destinationBaseURL) } + + let destinationDefaultsSuite = "com.clipbored.archiveimport.\(UUID().uuidString)" + let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite)) + destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) + defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) } + + let destinationSettings = SettingsModel(defaults: destinationDefaults) + destinationSettings.maxHistoryItems = 50 + destinationSettings.historyRetention = .forever + let destinationCache = ClipboardCacheService( + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + let destinationStore = ClipboardStore( + settings: destinationSettings, + cacheService: destinationCache, + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + destinationStore.upsert(makeItem("destination only", displayText: "Destination", created: created.addingTimeInterval(3))) + destinationStore.flushPersistenceForTesting() + + let importSummary = try destinationStore.importArchive(from: archiveURL) + destinationStore.flushPersistenceForTesting() + + XCTAssertEqual(importSummary.itemCount, 3) + XCTAssertEqual(importSummary.sidecarCount, 3) + XCTAssertEqual(destinationStore.items.count, 4) + XCTAssertTrue(destinationStore.items.contains { $0.payload == "destination only" }) + + let importedNote = try XCTUnwrap(destinationStore.items.first { $0.id == note.id }) + XCTAssertEqual(importedNote.collectionName, "Read Later") + XCTAssertEqual(importedNote.customTitle, "Migration Note") + XCTAssertEqual(importedNote.sourceDeviceName, "Studio Mac") + + let importedImage = try XCTUnwrap(destinationStore.items.first { $0.id == image.id }) + XCTAssertTrue(importedImage.isPinned) + XCTAssertEqual(importedImage.collectionName, "Client Work") + XCTAssertEqual(importedImage.customTitle, "Launch Screenshot") + XCTAssertEqual(importedImage.sourceDeviceName, "MacBook Pro") + let importedImagePath = try XCTUnwrap(importedImage.imagePath) + let importedThumbPath = try XCTUnwrap(importedImage.thumbnailPath) + XCTAssertTrue(importedImagePath.hasPrefix(destinationBaseURL.path)) + XCTAssertTrue(importedThumbPath.hasPrefix(destinationBaseURL.path)) + XCTAssertNotEqual(importedImagePath, cachedImage.full) + XCTAssertEqual(destinationCache.data(for: importedImagePath), sourceFullImageData) + XCTAssertEqual(destinationCache.data(for: importedThumbPath), sourceThumbData) + + let importedPDF = try XCTUnwrap(destinationStore.items.first { $0.id == pdf.id }) + XCTAssertEqual(importedPDF.collectionName, "Research") + XCTAssertTrue(importedPDF.payload.hasPrefix(destinationBaseURL.path)) + XCTAssertNotEqual(importedPDF.payload, sourcePDFPath) + XCTAssertEqual(destinationCache.data(for: importedPDF.payload), sourcePDFData) + + let reloaded = ClipboardStore( + settings: destinationSettings, + cacheService: destinationCache, + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + reloaded.flushPersistenceForTesting() + XCTAssertEqual(reloaded.items.count, 4) + XCTAssertTrue(reloaded.items.contains { $0.id == image.id && $0.collectionName == "Client Work" }) + XCTAssertTrue(reloaded.items.contains { $0.id == pdf.id && $0.collectionName == "Research" }) + } + + func testPinboardExportImportsOnlyThatCollectionAndPreservesColor() throws { + let settings = makeSettings(maxHistory: 50) + settings.ensureCollection(named: "Client Work", colorHex: "#3366FF") + let store = makeStore(settings: settings) + let created = Date(timeIntervalSince1970: 500) + var client = makeItem("client note", displayText: "Client Note", created: created) + client.collectionName = "Client Work" + let unrelated = makeItem("other note", displayText: "Other Note", created: created.addingTimeInterval(1)) + + store.upsert(client) + store.upsert(unrelated) + store.flushPersistenceForTesting() + + let archiveURL = baseURL.appendingPathComponent("client-work.clipboredarchive") + let exportSummary = try store.exportCollection(named: "Client Work", to: archiveURL) + + XCTAssertEqual(exportSummary.itemCount, 1) + XCTAssertEqual(exportSummary.sidecarCount, 0) + + let destinationDefaultsSuite = "com.clipbored.pinboardimport.\(UUID().uuidString)" + let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite)) + destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) + defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) } + let destinationSettings = SettingsModel(defaults: destinationDefaults) + destinationSettings.maxHistoryItems = 50 + destinationSettings.historyRetention = .forever + let destinationBaseURL = baseURL.appendingPathComponent("pinboard-destination", isDirectory: true) + let destinationCache = ClipboardCacheService( + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + let destinationStore = ClipboardStore( + settings: destinationSettings, + cacheService: destinationCache, + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + + let importSummary = try destinationStore.importArchive(from: archiveURL) + + XCTAssertEqual(importSummary.itemCount, 1) + XCTAssertEqual(destinationStore.items.map(\.payload), ["client note"]) + XCTAssertEqual(destinationStore.items.first?.collectionName, "Client Work") + XCTAssertEqual(destinationSettings.customCollectionNames, ["Client Work"]) + XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Client Work"), "#3366FF") + } + + func testEmptyPinboardExportImportsCollectionMetadata() throws { + let settings = makeSettings(maxHistory: 50) + settings.ensureCollection(named: "Read Later", colorHex: "#0A9EB8") + let store = makeStore(settings: settings) + let archiveURL = baseURL.appendingPathComponent("read-later-empty.clipboredarchive") + + let exportSummary = try store.exportCollection(named: "Read Later", to: archiveURL) + + XCTAssertEqual(exportSummary.itemCount, 0) + + let destinationDefaultsSuite = "com.clipbored.emptypinboard.\(UUID().uuidString)" + let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite)) + destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) + defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) } + let destinationSettings = SettingsModel(defaults: destinationDefaults) + let destinationBaseURL = baseURL.appendingPathComponent("empty-pinboard-destination", isDirectory: true) + let destinationStore = ClipboardStore( + settings: destinationSettings, + cacheService: ClipboardCacheService(baseURL: destinationBaseURL, encryptionService: noOpEncryptionService()), + baseURL: destinationBaseURL, + encryptionService: noOpEncryptionService() + ) + + let importSummary = try destinationStore.importArchive(from: archiveURL) + + XCTAssertEqual(importSummary.itemCount, 0) + XCTAssertTrue(destinationStore.items.isEmpty) + XCTAssertEqual(destinationSettings.customCollectionNames, ["Read Later"]) + XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Read Later"), "#0A9EB8") + } + private func makeSettings(maxHistory: Int) -> SettingsModel { let settings = SettingsModel(defaults: defaults) settings.maxHistoryItems = maxHistory + settings.historyRetention = .forever settings.pruneDuplicates = true settings.keepFirstImage = true return settings diff --git a/tests/clipboredtests/DiagnosticsServiceTests.swift b/tests/clipboredtests/DiagnosticsServiceTests.swift index 806b0db..a107994 100644 --- a/tests/clipboredtests/DiagnosticsServiceTests.swift +++ b/tests/clipboredtests/DiagnosticsServiceTests.swift @@ -12,8 +12,8 @@ final class DiagnosticsServiceTests: XCTestCase { diagnostics.incrementDatabaseMutation() diagnostics.incrementCachePurge() - // The counters use a serial async queue for low overhead; sync through reset's queue by reading a snapshot. - let snapshot = waitForSnapshot { diagnostics.currentSnapshot() } + // 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) @@ -23,13 +23,4 @@ final class DiagnosticsServiceTests: XCTestCase { diagnostics.reset() XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0)) } - - private func waitForSnapshot(_ snapshot: @escaping () -> DiagnosticsService.Snapshot) -> DiagnosticsService.Snapshot { - let expectation = expectation(description: "diagnostics queue") - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1) - return snapshot() - } } diff --git a/tests/clipboredtests/LinkPreviewWindowControllerTests.swift b/tests/clipboredtests/LinkPreviewWindowControllerTests.swift new file mode 100644 index 0000000..89a508a --- /dev/null +++ b/tests/clipboredtests/LinkPreviewWindowControllerTests.swift @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..fb8487c --- /dev/null +++ b/tests/clipboredtests/OnboardingWindowControllerTests.swift @@ -0,0 +1,75 @@ +import AppKit +import XCTest +@testable import ClipBored + +final class OnboardingWindowControllerTests: XCTestCase { + func testPasteStyleShortcutUsesShiftCommandV() { + let shortcut = OnboardingWindowController.pasteStyleOpenShortcut + + XCTAssertEqual(shortcut.key, "v") + XCTAssertTrue(shortcut.has(.command)) + XCTAssertTrue(shortcut.has(.shift)) + XCTAssertFalse(shortcut.has(.option)) + XCTAssertFalse(shortcut.has(.control)) + } + + func testFreshDefaultShortcutStartsWithPasteStyleChoice() { + let choice = OnboardingWindowController.initialShortcutChoice( + for: AppConfiguration.defaultOpenShortcut, + onboardingCompleted: false + ) + + XCTAssertEqual(choice, .pasteStyle) + } + + func testCompletedDefaultShortcutKeepsClipBoredDefaultChoice() { + let choice = OnboardingWindowController.initialShortcutChoice( + for: AppConfiguration.defaultOpenShortcut, + onboardingCompleted: true + ) + + XCTAssertEqual(choice, .clipBoredDefault) + } + + func testPresentationChoiceKeepsAVisibleEntryPoint() { + let presentation = OnboardingWindowController.normalizedPresentation( + showMenuBarIcon: false, + showDockIcon: false + ) + + XCTAssertTrue(presentation.showMenuBarIcon) + XCTAssertFalse(presentation.showDockIcon) + } + + func testPresentationChoicePreservesExplicitDockOnlySetup() { + let presentation = OnboardingWindowController.normalizedPresentation( + showMenuBarIcon: false, + showDockIcon: true + ) + + XCTAssertFalse(presentation.showMenuBarIcon) + 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/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index 527077a..8923fba 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -13,7 +13,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyWritesTextToPasteboard() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .text, @@ -33,7 +33,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyWritesCodeSnippetAsPlainString() { - let service = PasteActionService() + let service = makeService() let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}" let item = ClipboardItem( id: UUID(), @@ -54,7 +54,7 @@ final class PasteActionServiceTests: XCTestCase { } func testPasteboardWritersExposeTextForDragOut() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .text, @@ -77,7 +77,7 @@ final class PasteActionServiceTests: XCTestCase { } func testPasteboardWritersExposeURLAndTitleForDragOut() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .url, @@ -104,8 +104,64 @@ final class PasteActionServiceTests: XCTestCase { ) } + func testCopyMultipleItemsPreservesOriginalPasteboardRepresentations() throws { + let service = makeService() + let link = ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Apple", + payload: "https://apple.com", + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + let text = makeTextItem("Batch note") + + XCTAssertEqual(service.copy([link, text]), .copied) + let pasteboardItems = try XCTUnwrap(NSPasteboard.general.pasteboardItems) + XCTAssertEqual(pasteboardItems.count, 2) + XCTAssertEqual(pasteboardItems[0].string(forType: .URL), "https://apple.com") + XCTAssertEqual( + pasteboardItems[0].string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")), + "Apple" + ) + XCTAssertEqual(pasteboardItems[1].string(forType: .string), "Batch note") + } + + func testCopyMultipleItemsDoesNotClearPasteboardWhenAnyItemIsUnwritable() { + let service = makeService() + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("clipbored-missing-\(UUID().uuidString)") + .path + let missingFile = ClipboardItem( + id: UUID(), + kind: .file, + displayText: "Missing file", + payload: missingPath, + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("keep me", forType: .string) + + XCTAssertEqual( + service.copy([makeTextItem("Writable"), missingFile]), + .failed("Could not write items to clipboard.") + ) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "keep me") + } + func testCopyWritesColorToPasteboardWithHexFallback() throws { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .color, @@ -129,7 +185,7 @@ final class PasteActionServiceTests: XCTestCase { } func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .text, @@ -152,7 +208,7 @@ final class PasteActionServiceTests: XCTestCase { var activatedProcessID: pid_t? let targetApp = try makeRunningTargetApp() var didScheduleKeyboardPaste = false - let service = PasteActionService( + let service = makeService( accessibilityPermissionProvider: { true }, targetActivator: { app in activatedProcessID = app.processIdentifier @@ -173,7 +229,7 @@ final class PasteActionServiceTests: XCTestCase { var activatedProcessID: pid_t? let targetApp = try makeRunningTargetApp() var didScheduleKeyboardPaste = false - let service = PasteActionService( + let service = makeService( accessibilityPermissionProvider: { true }, targetActivator: { app in activatedProcessID = app.processIdentifier @@ -207,7 +263,7 @@ final class PasteActionServiceTests: XCTestCase { func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws { var didAttemptActivation = false let targetApp = try makeRunningTargetApp() - let service = PasteActionService( + let service = makeService( accessibilityPermissionProvider: { true }, targetActivator: { _ in didAttemptActivation = true @@ -225,7 +281,7 @@ final class PasteActionServiceTests: XCTestCase { func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws { let targetApp = try makeRunningTargetApp() - let service = PasteActionService( + let service = makeService( accessibilityPermissionProvider: { false }, targetActivator: { _ in XCTFail("Target should not be activated without Accessibility permission") @@ -241,7 +297,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyMissingFileDoesNotClearExistingPasteboard() { - let service = PasteActionService() + let service = makeService() let board = NSPasteboard.general board.clearContents() XCTAssertTrue(board.setString("keep me", forType: .string)) @@ -265,7 +321,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyEmptyTextDoesNotClearExistingPasteboard() { - let service = PasteActionService() + let service = makeService() let board = NSPasteboard.general board.clearContents() XCTAssertTrue(board.setString("keep me", forType: .string)) @@ -289,7 +345,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyWritesURLType() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .url, @@ -376,7 +432,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .richText, @@ -398,7 +454,7 @@ final class PasteActionServiceTests: XCTestCase { func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws { let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .richText, @@ -419,7 +475,7 @@ final class PasteActionServiceTests: XCTestCase { } func testCopyPlainTextForURLOmitsURLPasteboardTypes() { - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .url, @@ -442,7 +498,7 @@ final class PasteActionServiceTests: XCTestCase { func testCopyWritesFileReferenceType() throws { let fileURL = try makeTempFile(contents: "file contents") - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .file, @@ -467,7 +523,7 @@ final class PasteActionServiceTests: XCTestCase { let firstURL = try makeTempFile(contents: "first file") let secondURL = try makeTempFile(contents: "second file") let payload = FilePayload.payload(from: [firstURL, secondURL]) - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .file, @@ -491,7 +547,7 @@ final class PasteActionServiceTests: XCTestCase { func testCopyWritesPDFData() throws { let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8) let fileURL = try makeTempFile(contents: pdfData) - let service = PasteActionService() + let service = makeService() let item = ClipboardItem( id: UUID(), kind: .pdf, @@ -585,6 +641,36 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData) } + private func makeService( + accessibilityPermissionProvider: @escaping () -> Bool = { false }, + targetActivator: @escaping (NSRunningApplication) -> Bool = { _ in false }, + keyboardPasteScheduler: @escaping (@escaping () -> Void) -> Void = { _ in } + ) -> PasteActionService { + PasteActionService( + cacheService: makeNoOpCacheService(), + accessibilityPermissionProvider: accessibilityPermissionProvider, + targetActivator: targetActivator, + keyboardPasteScheduler: keyboardPasteScheduler + ) + } + + private func makeNoOpCacheService() -> ClipboardCacheService { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("clipboredtests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + tempURLs.append(directory) + return ClipboardCacheService(baseURL: directory, encryptionService: noOpEncryptionService()) + } catch { + XCTFail("Could not create no-op cache directory: \(error)") + return ClipboardCacheService( + baseURL: FileManager.default.temporaryDirectory, + encryptionService: noOpEncryptionService() + ) + } + } + private func makeTempFile(contents: String) throws -> URL { try makeTempFile(contents: Data(contents.utf8)) } @@ -633,4 +719,8 @@ final class PasteActionServiceTests: XCTestCase { let keyData = Data(repeating: byte, count: 32) return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) }) } + + private func noOpEncryptionService() -> ClipboardEncryptionService { + ClipboardEncryptionService(keyProvider: { nil }) + } } diff --git a/tests/clipboredtests/SettingsModelTests.swift b/tests/clipboredtests/SettingsModelTests.swift index c71b6b5..78a75c1 100644 --- a/tests/clipboredtests/SettingsModelTests.swift +++ b/tests/clipboredtests/SettingsModelTests.swift @@ -2,6 +2,43 @@ import XCTest @testable import ClipBored final class SettingsModelTests: XCTestCase { + func testFreshProfileRequiresOnboardingUntilCompleted() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertFalse(settings.onboardingCompleted) + XCTAssertFalse(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted)) + + settings.markOnboardingCompleted() + + XCTAssertTrue(settings.onboardingCompleted) + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted)) + XCTAssertEqual(changes, [.other]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertTrue(restored.onboardingCompleted) + } + + func testLegacyProfileDoesNotShowOnboardingAfterUpgrade() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + defaults.set(AppConfiguration.defaultHistoryLength, forKey: SettingsModel.Keys.maxHistoryItems) + + let settings = SettingsModel(defaults: defaults) + + XCTAssertTrue(settings.onboardingCompleted) + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted)) + } + func testShowDockIconPersistsAndNotifies() { let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -26,6 +63,383 @@ final class SettingsModelTests: XCTestCase { XCTAssertTrue(restored.showDockIcon) } + func testHideFromScreenCapturePersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertFalse(settings.hideFromScreenCapture) + + settings.hideFromScreenCapture = true + + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.hideFromScreenCapture)) + XCTAssertEqual(changes, [.hideFromScreenCapture]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertTrue(restored.hideFromScreenCapture) + } + + func testCompactModePersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertFalse(settings.compactMode) + + settings.compactMode = true + + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.compactMode)) + XCTAssertEqual(changes, [.compactMode]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertTrue(restored.compactMode) + } + + func testPanelSidePersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertEqual(settings.panelLayout, .vertical) + XCTAssertEqual(settings.panelSide, .right) + + settings.panelSide = .left + + XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelLayout), ClipboardPanelLayout.vertical.rawValue) + XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelSide), ClipboardPanelSide.left.rawValue) + XCTAssertEqual(changes, [.panelLayout]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.panelLayout, .vertical) + XCTAssertEqual(restored.panelSide, .left) + } + + func testImageCacheMinimumAllowsTwoMegabytes() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let twoMegabytes = Int64(2 * 1024 * 1024) + defaults.set(twoMegabytes, forKey: SettingsModel.Keys.imageCacheMaxBytes) + + let settings = SettingsModel(defaults: defaults) + + XCTAssertEqual(AppConfiguration.minCacheMaxBytes, twoMegabytes) + XCTAssertEqual(settings.imageCacheMaxBytes, twoMegabytes) + } + + func testICloudSyncPersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertFalse(settings.iCloudSyncEnabled) + + settings.setCloudSyncStatus(message: "Synced 3 clips to iCloud.") + XCTAssertEqual(settings.cloudSyncStatusMessage, "Synced 3 clips to iCloud.") + changes.removeAll() + + settings.iCloudSyncEnabled = true + + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.iCloudSyncEnabled)) + XCTAssertEqual(settings.cloudSyncStatusMessage, "") + XCTAssertEqual(changes, [.cloudSync]) + + settings.setCloudSyncStatus(message: "Synced 3 clips to iCloud.") + changes.removeAll() + settings.iCloudSyncEnabled = false + + XCTAssertFalse(defaults.bool(forKey: SettingsModel.Keys.iCloudSyncEnabled)) + XCTAssertEqual(settings.cloudSyncStatusMessage, "") + XCTAssertEqual(changes, [.cloudSync]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertFalse(restored.iCloudSyncEnabled) + } + + func testPanelShelfHeightPersistsAndNotifies() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertEqual(settings.panelShelfHeight, 0) + + settings.panelShelfHeight = 620 + + XCTAssertEqual(defaults.double(forKey: SettingsModel.Keys.panelShelfHeight), 620) + XCTAssertEqual(changes, [.panelSizing]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.panelShelfHeight, 620) + } + + func testPauseCaptureUntilPersistsAndCanBeCleared() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + let pauseUntil = Date(timeIntervalSince1970: 1_234_567) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.pauseCapture = true + settings.pauseCaptureUntil = pauseUntil + + XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.pauseCapture)) + XCTAssertEqual(defaults.double(forKey: SettingsModel.Keys.pauseCaptureUntil), pauseUntil.timeIntervalSince1970) + XCTAssertEqual(changes, [.pauseCapture, .pauseCapture]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertTrue(restored.pauseCapture) + XCTAssertEqual(restored.pauseCaptureUntil, pauseUntil) + + restored.pauseCaptureUntil = nil + + XCTAssertNil(defaults.object(forKey: SettingsModel.Keys.pauseCaptureUntil)) + } + + func testIgnoredAppsPersistsAndNotifiesNarrowChange() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.ignoredApps = ["Safari", "Xcode"] + + XCTAssertEqual(defaults.stringArray(forKey: SettingsModel.Keys.ignoredApps), ["Safari", "Xcode"]) + XCTAssertEqual(changes, [.ignoredApps]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.ignoredApps, ["Safari", "Xcode"]) + } + + func testCommonControlSettingsNotifyNarrowChanges() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.defaultSortMode = .links + settings.includeImageTextInSearch = true + settings.pruneDuplicates = false + settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue] + settings.keepFirstImage = false + settings.excludeSensitive = true + settings.clearHistoryOnQuit = true + + XCTAssertEqual(changes, [ + .defaultSortMode, + .includeImageTextInSearch, + .pruneDuplicates, + .ignoredItemKinds, + .keepFirstImage, + .excludeSensitive, + .clearHistoryOnQuit + ]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.defaultSortMode, .links) + XCTAssertTrue(restored.includeImageTextInSearch) + XCTAssertFalse(restored.pruneDuplicates) + XCTAssertEqual(restored.ignoredItemKindsRaw, [ClipboardItemKind.image.rawValue]) + XCTAssertFalse(restored.keepFirstImage) + XCTAssertTrue(restored.excludeSensitive) + XCTAssertTrue(restored.clearHistoryOnQuit) + } + + func testIgnoredItemKindsCannotDisableEveryVisibleKind() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + let allVisibleKindRawValues = Self.visibleItemKinds.map(\.rawValue) + let expectedIgnoredKindRawValues = allVisibleKindRawValues.filter { + $0 != ClipboardItemKind.text.rawValue + } + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.ignoredItemKindsRaw = allVisibleKindRawValues + + XCTAssertEqual(settings.ignoredItemKindsRaw, expectedIgnoredKindRawValues) + XCTAssertEqual( + defaults.object(forKey: SettingsModel.Keys.ignoredItemKinds) as? [Int], + expectedIgnoredKindRawValues + ) + XCTAssertEqual(changes, [.ignoredItemKinds]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.ignoredItemKindsRaw, expectedIgnoredKindRawValues) + } + + func testStoredIgnoredItemKindsCannotDisableEveryVisibleKind() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let allVisibleKindRawValues = Self.visibleItemKinds.map(\.rawValue) + let expectedIgnoredKindRawValues = allVisibleKindRawValues.filter { + $0 != ClipboardItemKind.text.rawValue + } + defaults.set(allVisibleKindRawValues, forKey: SettingsModel.Keys.ignoredItemKinds) + + let settings = SettingsModel(defaults: defaults) + + XCTAssertEqual(settings.ignoredItemKindsRaw, expectedIgnoredKindRawValues) + XCTAssertEqual( + defaults.object(forKey: SettingsModel.Keys.ignoredItemKinds) as? [Int], + expectedIgnoredKindRawValues + ) + } + + func testStoredImageCacheLimitIsClampedToSettingsRange() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let zeroDefaults = UserDefaults(suiteName: "\(suiteName).zero")! + let lowDefaults = UserDefaults(suiteName: "\(suiteName).low")! + let highDefaults = UserDefaults(suiteName: "\(suiteName).high")! + defer { + zeroDefaults.removePersistentDomain(forName: "\(suiteName).zero") + lowDefaults.removePersistentDomain(forName: "\(suiteName).low") + highDefaults.removePersistentDomain(forName: "\(suiteName).high") + } + + zeroDefaults.set(0, forKey: SettingsModel.Keys.imageCacheMaxBytes) + let zeroSettings = SettingsModel(defaults: zeroDefaults) + + XCTAssertEqual(zeroSettings.imageCacheMaxBytes, AppConfiguration.defaultCacheMaxBytes) + XCTAssertEqual(Int64(zeroDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.defaultCacheMaxBytes) + + lowDefaults.set(1 * 1024 * 1024, forKey: SettingsModel.Keys.imageCacheMaxBytes) + let lowSettings = SettingsModel(defaults: lowDefaults) + + XCTAssertEqual(lowSettings.imageCacheMaxBytes, AppConfiguration.minCacheMaxBytes) + XCTAssertEqual(Int64(lowDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.minCacheMaxBytes) + + highDefaults.set(2048 * 1024 * 1024, forKey: SettingsModel.Keys.imageCacheMaxBytes) + let highSettings = SettingsModel(defaults: highDefaults) + + XCTAssertEqual(highSettings.imageCacheMaxBytes, AppConfiguration.maxCacheMaxBytes) + XCTAssertEqual(Int64(highDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.maxCacheMaxBytes) + } + + func testStoredHistoryLimitIsClampedToSettingsRange() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let zeroDefaults = UserDefaults(suiteName: "\(suiteName).zero")! + let lowDefaults = UserDefaults(suiteName: "\(suiteName).low")! + let highDefaults = UserDefaults(suiteName: "\(suiteName).high")! + defer { + zeroDefaults.removePersistentDomain(forName: "\(suiteName).zero") + lowDefaults.removePersistentDomain(forName: "\(suiteName).low") + highDefaults.removePersistentDomain(forName: "\(suiteName).high") + } + + zeroDefaults.set(0, forKey: SettingsModel.Keys.maxHistoryItems) + let zeroSettings = SettingsModel(defaults: zeroDefaults) + + XCTAssertEqual(zeroSettings.maxHistoryItems, AppConfiguration.defaultHistoryLength) + XCTAssertEqual(zeroDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.defaultHistoryLength) + + lowDefaults.set(1, forKey: SettingsModel.Keys.maxHistoryItems) + let lowSettings = SettingsModel(defaults: lowDefaults) + + XCTAssertEqual(lowSettings.maxHistoryItems, AppConfiguration.minHistoryLength) + XCTAssertEqual(lowDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.minHistoryLength) + + highDefaults.set(100_000, forKey: SettingsModel.Keys.maxHistoryItems) + let highSettings = SettingsModel(defaults: highDefaults) + + XCTAssertEqual(highSettings.maxHistoryItems, AppConfiguration.maxHistoryLength) + XCTAssertEqual(highDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.maxHistoryLength) + } + + func testLimitAssignmentsAreClampedAndPersisted() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + settings.maxHistoryItems = 1 + settings.maxHistoryItems = 100_000 + settings.imageCacheMaxBytes = 1 + settings.imageCacheMaxBytes = 2048 * 1024 * 1024 + + XCTAssertEqual(settings.maxHistoryItems, AppConfiguration.maxHistoryLength) + XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.maxHistoryLength) + XCTAssertEqual(settings.imageCacheMaxBytes, AppConfiguration.maxCacheMaxBytes) + XCTAssertEqual(Int64(defaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.maxCacheMaxBytes) + XCTAssertEqual(changes, [ + .maxHistoryItems, + .maxHistoryItems, + .imageCacheMaxBytes, + .imageCacheMaxBytes + ]) + } + + func testHistoryRetentionDefaultsToOneMonthAndPersists() { + let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let settings = SettingsModel(defaults: defaults) + var changes: [SettingsModel.Change] = [] + settings.observe { changes.append($0) } + + XCTAssertEqual(settings.historyRetention, .oneMonth) + XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.historyRetention), HistoryRetention.oneMonth.rawValue) + + settings.historyRetention = .oneWeek + + XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.historyRetention), HistoryRetention.oneWeek.rawValue) + XCTAssertEqual(changes, [.historyRetention]) + + let restored = SettingsModel(defaults: defaults) + XCTAssertEqual(restored.historyRetention, .oneWeek) + } + func testCustomCollectionsPersistWithNormalizedColors() { let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -77,4 +491,17 @@ final class SettingsModelTests: XCTestCase { XCTAssertEqual(restored.customCollectionNames, ["Product Research"]) XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF") } + + private static let visibleItemKinds: [ClipboardItemKind] = [ + .text, + .code, + .url, + .image, + .color, + .audio, + .video, + .richText, + .pdf, + .file + ] } diff --git a/tests/clipboredtests/SettingsWindowControllerTests.swift b/tests/clipboredtests/SettingsWindowControllerTests.swift new file mode 100644 index 0000000..6923889 --- /dev/null +++ b/tests/clipboredtests/SettingsWindowControllerTests.swift @@ -0,0 +1,1064 @@ +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, 620, accuracy: 0.5) + XCTAssertEqual(controller.debugWindowMinSize.height, 560, accuracy: 0.5) + XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.width, 700) + XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.height, 600) + } + + 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 testSettingsDataTabKeepsExtraNativeHighlightPadding() { + let (controller, _) = makeController() + + XCTAssertEqual(controller.debugSettingsTabLayoutMetrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]) + XCTAssertEqual(controller.debugRawSettingsTabLabels.last?.clipboardTrimmed, "Data") + XCTAssertGreaterThan(controller.debugRawSettingsTabLabels.last?.count ?? 0, "Data".count) + } + + 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: 620, height: 560)) + + 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 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 101d969..7ee391a 100644 --- a/tests/clipboredtests/ShortcutManagerTests.swift +++ b/tests/clipboredtests/ShortcutManagerTests.swift @@ -56,6 +56,13 @@ final class ShortcutManagerTests: XCTestCase { manager.stop() } + func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() { + let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut) + + XCTAssertEqual(manager.start(), .conflict(ShortcutManager.stackCaptureShortcut.displayText)) + manager.stop() + } + private func makeManager( openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut