From 52f712eb731fabb2f6436bffa3a2bafd04095b5f Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Thu, 9 Jul 2026 19:32:57 -0400 Subject: [PATCH] Polish clipboard panel UX and interactions --- README.md | 77 +- docs/ARCHITECTURE.md | 98 +- docs/ROADMAP.md | 6 +- docs/SMOKE_TEST.md | 184 +- docs/thingstofix.md | 26 +- sources/clipbored/app/AppDelegate.swift | 118 +- sources/clipbored/models/SettingsModel.swift | 43 +- .../clipbored/services/ShortcutManager.swift | 39 +- .../views/ClipboardPanelController.swift | 54 +- .../clipbored/views/ClipboardPanelView.swift | 2260 +++++------------ .../views/ClipboardPanelViewModel.swift | 812 +++--- .../views/SettingsWindowController.swift | 213 +- .../ClipboardPanelControllerTests.swift | 100 +- .../ClipboardPanelViewModelTests.swift | 440 +++- .../ClipboardPanelViewTests.swift | 764 ++---- tests/clipboredtests/SettingsModelTests.swift | 47 +- .../SettingsWindowControllerTests.swift | 71 +- .../clipboredtests/ShortcutManagerTests.swift | 30 +- 18 files changed, 2109 insertions(+), 3273 deletions(-) diff --git a/README.md b/README.md index aa3c8a8..3ad45c0 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,51 @@ # ClipBored -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. +ClipBored is a native macOS clipboard manager with a keyboard-first side shelf. It captures local clipboard history and makes clips easy to find, preview, organize, copy, paste, pin, and delete. It runs as a dockless menu-bar utility by default, with an optional Dock icon. 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 -- 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: - - `Shift + Command + V` or the configured open shortcut toggles the clipboard panel +- A compact toolbar with an expanding search control, clear-history and settings actions, plus a vertically scrollable category icon rail beside the cards +- Category filtering without a separate filter panel: click a chip to select it, or Command-click chips to show their union; unused empty built-in categories stay hidden while named Pinboards remain available even when empty +- A vertically scrolling list of cards with full-color kind or Pinboard headers that fills the space beside the category rail. Hover expands a card's preview without changing keyboard selection or filtering; commands live in card context menus and keyboard shortcuts +- Search that collapses to a magnifying-glass button when it is empty and unfocused, expands on click, typing, or `Command + F`, and stays expanded while a query is active +- Fast, short panel, search, category, and card transitions that honor the macOS Reduce Motion accessibility setting +- First-run setup for the open shortcut, Keep History retention, menu-bar/Dock presence, launch at login, iCloud sync, and Accessibility permission +- A menu-bar status menu with capture state, history count, settings, manual or timed pause/resume, and quit +- Global and panel shortcuts: + - The configured open shortcut toggles the clipboard shelf - `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 + F` focuses search + - `Command + 1` through `Command + 9` pastes the numbered visible card; add `Shift` to use plain text + - `Return` pastes the selected clip; `Shift + Return` or `Command + Shift + V` uses 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 + - `Command + E` edits the selected text or code clip, and `Command + R` renames it - `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 + G` shows a filtered result 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 - - `Shift + Command + C` toggles Stack capture mode for queued multi-paste workflows + - `Shift + Command + N` creates a Pinboard collection + - `Shift + Command + C` toggles Stack capture 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 + - `Command + A` selects the visible list from a focused card; Shift-modified navigation extends a 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 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 +- Keyboard-focusable cards and category chips with type-to-search, visible focus chrome, VoiceOver descriptions, context menus, Up/Down card navigation, and direct category navigation +- Clipboard history for text, code, colors, URLs, images, audio, video, RTF/HTML rich text, PDFs, and file references +- Immediate cards with previews loaded asynchronously. Image, link, document, and movie thumbnails fill in without blocking search, selection, or scrolling +- Built-in browser previews for links, Quick Look for files and media, image rotation, and local text extraction for images +- Independent-token and structured search, including `app:Safari`, `type:image,pdf`, `device:MacBook`, `pinboard:"Client Work","Read Later"`, and `date:2026-06-30` +- Custom color-coded Pinboards, including empty Pinboards, with drag-and-drop collection assignment, context-menu editing and export, and durable retention +- Searchable custom clip titles that do not alter the copied payload +- Multi-selection, original-format or plain-text batch copy/paste, Stack capture, next-item Stack actions, and Stack-as-text workflows - 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 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 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 -- 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 +- A bounded thumbnail cache with asynchronous loading and byte/file-count pruning +- Portable local archive export/import for history, Pinboards, and managed attachments; external file references stay path-based +- Optional iCloud archive sync for signed builds with an iCloud entitlement +- A redesigned, resizable Settings window with top-level `General`, `Shortcuts`, `Capture`, `Privacy`, `Performance`, and `Data` tabs. Each page is vertically scrollable and keeps controls aligned at narrow window sizes +- Settings for shelf side, retention, history length, default sort, cache limit, adaptive polling profile, ignored apps, content kinds, shortcuts, launch at login, Dock/menu-bar presence, screen-capture privacy, capture pause, and clear-on-quit behavior +- Local-first storage, with optional sensitive-content exclusion and iCloud sync disabled by default ## Requirements @@ -79,8 +79,7 @@ swift test -q ./scripts/idle-soak-report.sh 900 ``` -For app-level behavior that cannot be fully covered by unit tests, run the manual checklist in [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md). -For distribution builds, see [docs/RELEASE.md](docs/RELEASE.md). +For app-level behavior that cannot be fully covered by unit tests, run [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md). For distribution builds, see [docs/RELEASE.md](docs/RELEASE.md). Project layout: @@ -89,16 +88,16 @@ Project layout: - `sources/clipbored/extensions` - small AppKit/Foundation helpers - `sources/clipbored/models` - clipboard item and settings models - `sources/clipbored/resources` - app bundle metadata and icon assets -- `sources/clipbored/services` - clipboard capture, persistence, cache, shortcuts, paste, diagnostics, privacy filters -- `sources/clipbored/views` - panel and settings UI -- `tests/clipboredtests` - unit tests for persistence, filtering, shortcuts, pasteboard writes, diagnostics, and sensitive-content detection -- `docs` - architecture, security notes, and roadmap +- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters +- `sources/clipbored/views` - panel, onboarding, preview, and settings UI +- `tests/clipboredtests` - unit and UI-structure regression tests +- `docs` - architecture, security, release, smoke-test, and roadmap notes ## Privacy And Security 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. 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. +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. Portable archive exports and iCloud sync archives are not encrypted by ClipBored, so treat them as sensitive backups. Temporary decrypted preview files are cleared on launch, cache/history clear, and quit. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure. ## Contributing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8497ac4..7a3a720 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,64 +1,76 @@ # Architecture -ClipBored is a single-process AppKit utility built with Swift Package Manager. +ClipBored is a single-process AppKit utility built with Swift Package Manager. UI, capture, persistence, preview generation, and paste orchestration stay in-process. Capture persistence, card-thumbnail loading, archive and sync operations, image rotation, and OCR use bounded background queues; their UI state and completion feedback return to the main thread. ## Runtime Shape -- `ClipBoredApp` creates `NSApplication`, sets accessory activation, installs `AppDelegate`, and starts the run loop. -- `AppDelegate` wires shared services, status menu items, settings observers, and global shortcuts. -- `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 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. +- `ClipBoredApp` creates `NSApplication`, installs `AppDelegate`, and starts the run loop. +- `AppDelegate` wires services, menu-bar commands, settings observers, and global shortcuts. +- `ClipboardMonitorService` watches `NSPasteboard.changeCount` on a utility queue with adaptive active/idle polling intervals selected by the Performance setting. +- `ClipboardStore` owns the in-memory item list and SQLite persistence on a serial queue. +- `ClipboardCacheService` stores bounded encrypted preview sidecars under Application Support and maintains a small in-memory cache. +- `ClipboardCloudSyncService` resolves the private iCloud ubiquity container and pushes or pulls portable archives only when sync is enabled. +- `ShortcutManager` registers only intentional system-wide Carbon hotkeys (open shelf and Stack capture). The Settings binding is handled by the shelf's local key monitor and is never registered globally. +- `ClipboardPanelController` owns shelf lifecycle, current-screen placement, left/right frame planning, target-app tracking, and show/hide/reflow animation. +- `ClipboardPanelViewModel` owns query parsing, indexed category unions, sorting, selection, copy/paste, pinning, Pinboards, Stack, deletion, opening, and asynchronous thumbnail request coalescing. +- `ClipboardPanelView` renders the toolbar, vertical category icon rail, and viewport-aware card list beside it. +- `LinkPreviewWindowController` opens user-selected HTTP(S) links in an ephemeral WebKit preview window. +- `OnboardingWindowController` handles first-run shortcut, retention, lifecycle, sync, and Accessibility choices. +- `SettingsWindowController` presents six resizable, vertically scrollable settings pages and routes common settings changes through targeted control refreshes. -## Data Flow +## Capture And Presentation Flow 1. The monitor notices a pasteboard change. -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; 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. +2. Capture rules check paused state, ignored source apps, allowed content kinds, and optional sensitive-text exclusion. +3. Pasteboard content is normalized into a `ClipboardItem`; local Vision OCR runs when image-label search is enabled. +4. The store deduplicates, preserves pinned and Pinboard-assigned items, enforces retention/length limits, and persists the mutation. +5. The panel view model maintains category/Pinboard indexes, applies the text query and selected category union, and caches parsed search matches across counts and category changes. +6. The view lays out card slots beside the category rail in one vertical document and materializes cards near the visible viewport. +7. Cards render immediately with a fallback presentation. Preview thumbnails load on a bounded operation queue; identical in-flight requests share work, and a still-relevant card is replaced in place on the main thread when its image arrives. -## Persistence +## Shelf Interaction Model -History is stored in SQLite at: +The shelf uses a fixed vertical layout on the configured left or right edge of the active screen. + +- Header row one contains the collapsed/expanded search control plus clear-history and settings actions. +- Header row two is a labeled, horizontally scrollable category rail. Built-in categories without clips are omitted unless currently selected; custom Pinboards remain available when empty. +- A normal chip click replaces the active category filter. Command-click adds or removes chips from a union. Hover changes chrome only and never changes filtering. +- An empty, unfocused search field collapses to an icon. Click, typing, or `Command + F` expands and focuses it; clicking elsewhere collapses it only when the query is empty. Repeated `Command + F` keeps focus in the same field. +- Cards scroll vertically and fill the usable shelf width. Hover expansion changes visual presentation only: it does not mutate keyboard selection, the selected range, or query/category state. +- Card commands are discoverable through context menus, VoiceOver descriptions, and keyboard shortcuts. Hover-only action controls are not part of the interaction contract. +- Category changes and card/search expansion use short AppKit/Core Animation transitions. Both panel-controller and panel-view durations resolve to zero when macOS Reduce Motion is enabled. + +The panel has no user-facing resize lip, alternate density/layout mode, close button, new-text action, or persistent status bar. It is dismissed with `Esc` or the configured global shortcut. + +## Settings UI + +Settings uses a custom segmented selector backed by a borderless `NSTabView`: + +- `General` - history, sorting, shelf side, launch, and menu-bar/Dock presence +- `Shortcuts` - a system-wide open-shelf binding and a pane-local open-settings binding +- `Capture` - pause state, content kinds, image-label search, likely-secret exclusion, ignored apps, and capture status +- `Privacy` - local-data behavior, screen-capture hiding, Accessibility permission, and paste status +- `Performance` - adaptive polling profile and thumbnail-cache cap +- `Data` - iCloud archive sync, local archive import/export, storage location, and destructive clears + +Each tab is a top-aligned document inside its own vertical scroll view. The window has a practical minimum size, no horizontal scrollers, and commits focused text/shortcut drafts when it closes. Common narrow settings changes update their bound controls without rebuilding the whole window; expensive cloud status checks are cached across unrelated refreshes. + +## Persistence And Privacy Boundaries + +History is stored in: ```text ~/Library/Application Support/ClipBored/history.sqlite ``` -Images are stored under: +Image previews are stored under `images/`; restorable audio, video, rich-text, and PDF payloads are stored under `attachments/`. Legacy JSON import remains for migration from early builds. -```text -~/Library/Application Support/ClipBored/images/ -``` +Portable `.clipboredarchive` files preserve item metadata, Pinboards, and decrypted bytes for app-managed sidecars so another Mac can re-cache them with its own storage paths and encryption key. External file references remain path-based. Optional iCloud sync uses the same unencrypted archive format inside the app-private ubiquity container and is disabled by default. -Restorable non-image payloads such as audio clips, rich text, and PDFs are stored under: - -```text -~/Library/Application Support/ClipBored/attachments/ -``` - -Legacy JSON import still exists for migration from early builds. - -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. +Textual SQLite fields are encrypted and decrypted at the `ClipboardStore` boundary. Managed cache and attachment files are encrypted and decrypted at the `ClipboardCacheService` boundary. The key lives in Keychain when available, with an owner-only local fallback if Keychain access fails. Runtime `ClipboardItem` values remain plaintext in memory for search and clipboard operations. Opening encrypted media may create a temporary decrypted file; stale 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 release build intentionally avoids SwiftUI, Combine, Swift Concurrency, third-party packages, and bundled media. The shelf avoids continuous layout work, renders only cards near the viewport, coalesces preview requests, keeps card-thumbnail decoding off the main thread, and bounds both memory and disk caches. Clipboard monitoring uses change-count polling with selectable adaptive profiles rather than continuous file scans. -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. +The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. `scripts/build-macos-app.sh` enforces 2 MiB gates for both the executable and app bundle. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 42d03fa..f7fe55f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,7 +15,8 @@ This roadmap keeps future work aligned with the project's constraints: small exe ## Product Polish -- Improve keyboard focus states and VoiceOver labels. +- Keep keyboard focus, VoiceOver descriptions, Command-click category unions, and context-menu parity covered as the side-rail shelf evolves. +- Validate motion, cross-display placement, and card expansion on each supported macOS release, including the system Reduce Motion path. - 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. @@ -24,4 +25,5 @@ This roadmap keeps future work aligned with the project's constraints: small exe - Keep measuring binary size after each feature. - Avoid continuous background file scans. - Revisit polling intervals only with measured idle wakeup evidence. -- Keep image decoding lazy and cache bounded. +- Track asynchronous preview latency and viewport materialization under large histories. +- Keep card-thumbnail decoding and user-triggered image transforms off the main thread, with bounded queues and caches. diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index dbdaf9d..7dad7c7 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -1,137 +1,93 @@ # Manual Smoke Test Checklist -Use this checklist before a release or after changes to panel, pasteboard, settings, permissions, storage, launch-at-login, or packaging behavior. +Use this checklist before a release or after changes to the shelf, pasteboard, settings, permissions, storage, launch-at-login, or packaging behavior. ## Setup -1. Build the app: +1. Run `./scripts/check.sh`, quit any running copy, and open `build/ClipBored.app`. +2. With fresh preferences, confirm the setup assistant appears before the shelf and covers the open shortcut, Keep History, menu-bar/Dock presence, launch at login, iCloud sync, and Accessibility. +3. Finish setup, relaunch, and confirm the assistant does not return. +4. Confirm the menu-bar icon appears when `Show ClipBored in the menu bar` is enabled. - ```bash - ./scripts/check.sh - ``` +## Capture And Preview Loading -2. Quit any running ClipBored copy. -3. Open `build/ClipBored.app`. -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. +1. Copy plain text, code, a URL, a color, an image, audio, video, rich text, a PDF, one Finder file, and multiple Finder files. Confirm each appears with the correct kind and grouped files remain one clip. +2. Confirm a new card appears immediately even when its preview is not cached. Keep searching and scrolling while the thumbnail loads, then confirm the card updates in place without changing selection or scroll position. +3. Confirm URL and video thumbnails appear when source data is available, and that a missing or failed thumbnail leaves a usable fallback card. +4. Enable `Search in image labels`, copy an image containing readable text, and confirm the OCR text is searchable after processing finishes. +5. Disable a content kind in Settings > Capture, copy that kind again, and confirm it is skipped. Re-enable it afterward. +6. Copy from an ignored source app and confirm the capture status reports that the item was skipped. -## Capture +## Shelf Chrome, Search, And Categories -1. Copy plain text from TextEdit, Notes, or a browser. -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 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 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. -12. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text. -13. Disable Images, Audio, Video, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured. +1. Open the shelf and confirm the collapsed search icon is at the toolbar's leading edge. Activate it and confirm the same liquid-glass pill expands smoothly to the right toward Clear History and Settings without moving its left edge or icon, with text and caret aligned after the icon. +2. With an empty query, confirm search is a circular magnifying-glass button. Click it or press `Command + F` and confirm it expands to an aligned search field without moving the card list. +3. Click a card or category while the empty search field is focused and confirm search collapses again. Enter a query and confirm it remains expanded after focus moves elsewhere; clearing the idle query collapses it. +4. Press `Command + F` repeatedly and confirm it keeps focus in the same search field. +5. Type a query and confirm results update immediately. Press `Esc` once to clear an active query and again to close the shelf. +6. Type a structured query such as `pinboard:"Client Work" type:image,pdf device:` and confirm the terms are combined with the text query. +7. Confirm the category icon rail scrolls vertically when its icons exceed the available height, while the card list scrolls independently beside it. +8. With sparse history, confirm built-in type/sort categories with zero matches are absent. Clipboard remains available, and empty custom Pinboards remain visible. +9. Click Text and confirm it replaces the current category filter. Command-click Links and confirm the list becomes the union of Text and Links; Command-click either selected chip again to remove it from the union. +10. Hover several category chips without clicking and confirm neither the active filters nor the visible clips change. +11. Tab to search, toolbar controls, chips, and cards. Confirm focus is visible and VoiceOver labels explain each action. Use Left/Right and Home/End on category icons, and Up/Down/Page/Home/End navigation on cards; Left/Right must not move card selection. -## Panel +## Cards, Selection, And Actions -1. Open the panel and confirm the search field is focused. -2. Type a query and confirm results filter immediately. -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 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. +1. Confirm cards form one vertical list beside the category rail, with full-color kind or Pinboard headers and readable alignment at both left and right shelf positions. +2. Hover an unselected card and confirm only that card's preview expands. The selected/focused card, selected range, visible results, and keyboard navigation must not change, and the content must remain unobscured. +3. Move focus with the keyboard while another card is hovered. Confirm focus moves from the keyboard selection and stale hover expansion clears. +4. Confirm card and category transitions feel continuous rather than snapping. Enable macOS System Settings > Accessibility > Display > Reduce Motion and confirm panel, search, category, and card transitions become immediate; restore the original system setting afterward. +5. Right-click representative text, link, image, file, and media cards. Confirm applicable commands appear in the context menu: paste/copy, plain-text variants, preview/open, edit/rename, pin/collect, Stack, image rotate/extract text, capture rules, show in Clipboard, and delete. +6. With a card focused, confirm `Return` pastes, `Shift + Return` uses plain text, `Command + C` copies, `Space` or `Command + Y` previews, `Command + O` opens applicable clips, `Command + E` edits text/code, and `Command + R` renames without changing the payload. +7. Press `Command + 1` through `Command + 9` and confirm the matching numbered cards are used; add `Shift` and confirm plain-text output. +8. Command-click non-adjacent cards and Shift-click a range. Confirm context-menu batch actions use the selected set and `Command + A` selects all visible cards. +9. Delete multiple selected clips, press `Command + Z`, and confirm the batch returns selected. +10. From a filtered result, choose Show in Clipboard or press `Command + G`; confirm search clears and the same clip stays selected in Clipboard. +11. Double-click a card and confirm paste or copy fallback occurs without creating a duplicate history item. +12. Confirm text cards do not repeat a one-line title in the body, multi-line text shows the remaining lines, files/PDFs use document previews, and missing source apps do not display `Unknown`. +13. On multiple displays and Spaces, confirm the shortcut opens on the pointer's active display/Space and the menu-bar icon opens on its display. Switch Settings > General > Shelf side and verify both left and right placement. + +## Pinboards And Stack + +1. Press `Shift + Command + N` or use the category-row add button to create an empty color-coded Pinboard named `Client Work`; confirm its labeled chip remains visible with no count pill. +2. Assign an existing card through Collect or drag it onto the Pinboard. Confirm its count, color, and assignment survive relaunch and normal history pruning. +3. Right-click the Pinboard chip to edit its name/color, export it, and delete it. Import the archive into a fresh profile and confirm clips plus empty Pinboard metadata are restored. +4. Press `Shift + Command + C`, copy two clips, and confirm Stack capture records them in copy order. Toggle capture off again. +5. From a card or Stack context menu, test Add Visible Clips to Stack, next-item paste/copy, Stack-as-text, and Clear Stack. Confirm queue order and duplicate protection. ## Copy And Paste -1. Select a text item and press the Copy button. Confirm the system clipboard contains that text. -2. Select a URL item and confirm the system clipboard contains both string and URL data by pasting into a browser address bar. -3. Select one-file and multi-file File items and paste into Finder or an app that accepts file references. Confirm all files are preserved for the multi-file item. -4. Select an audio item and paste into an app that accepts sound pasteboard data. -5. Select a Video item and paste into an app that accepts movie pasteboard data. -6. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data. -7. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field. -8. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only. -9. Without Accessibility permission, confirm paste actions copy and show the permission fallback status. -10. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item. +1. Copy/paste text, URL, image, audio, video, PDF, rich text, and file clips into suitable apps. Confirm original pasteboard representations are preserved. +2. Paste a multi-file clip into Finder and confirm all file references are present. +3. Use plain-text paste on URL and rich-text clips and confirm formatting and non-text representations are omitted. +4. Without Accessibility permission, confirm paste falls back to copying and Settings > Privacy reports the permission requirement. +5. With Accessibility permission, confirm paste returns focus to the previous app and inserts the selected clip. ## Settings -1. Open Settings with `Command + ,`. -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` 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. 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. +1. With the ClipBored pane open, open Settings with `Command + ,` and confirm a resizable window with six clean selector tabs: `General`, `Shortcuts`, `Capture`, `Privacy`, `Performance`, and `Data`. Close the pane, switch to another app, and confirm `Command + ,` opens that app's settings instead of ClipBored. +2. Resize to the practical minimum. Visit every tab and confirm content stays anchored to the top-left, scrolls vertically when needed, and has no horizontal clipping, zero-sized controls, or duplicate selector labels. +3. In General, change shelf side, Keep History, history length, default sort, launch-at-login, and menu-bar/Dock presence. Relaunch and confirm persistence. +4. In Shortcuts, change the open shortcut, close Settings while a field is focused, and confirm the edited binding is committed and the old shortcut no longer opens the shelf. +5. In Capture, pause/resume capture, toggle likely-secret exclusion and image-label search, edit ignored apps, and toggle allowed content types. Confirm at least one content type must remain enabled and status feedback stays inside this tab. +6. In Privacy, test clear-on-quit, screen-capture hiding, Accessibility settings, permission refresh, and paste-status feedback. +7. In Performance, change the polling profile and image-cache cap. Confirm capture continues at the selected cadence, UI input remains responsive while polling, and the values persist. +8. In Data, export/import an archive, exercise available iCloud controls, open the history folder, and confirm destructive clear actions require confirmation and show success or error feedback in the Data page. +9. Change one control and confirm unrelated tabs do not jump, reset, or flash; long status messages should wrap instead of widening the window. ## Storage And Privacy -1. Open the data folder from Settings > Data. -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. 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. +1. Open the data folder and confirm `history.sqlite`, `images/`, and `attachments/` appear as applicable. +2. Copy unique text and managed attachment data; confirm `strings` does not expose the test content from the SQLite database or encrypted sidecars. +3. Treat exported and iCloud `.clipboredarchive` files as sensitive because they are portable and not encrypted by ClipBored. +4. Open or reveal encrypted media, quit ClipBored, and confirm temporary preview files under `/tmp/ClipBored/Previews` are removed. +5. Clear history and confirm saved rows, managed attachments, temporary previews, and the fallback encryption key are removed when present. Clear the thumbnail cache separately and confirm history remains. +6. Enable `Clear history on quit`, relaunch, and confirm history and managed cache/attachment files were removed. ## 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, 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. 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. +1. Enable and disable Launch at Login and verify the next login behavior each time. +2. Right-click or Control-click the menu-bar icon and confirm the menu includes capture state/count, Show Clipboard, New Collection, Stack Capture, Settings, pause/resume options, and Quit. +3. Test a timed pause, manual pause, and resume; confirm copied items are skipped only while capture is paused. +4. Quit from the menu-bar menu and confirm no `ClipBored` process remains. diff --git a/docs/thingstofix.md b/docs/thingstofix.md index 1790ad6..6bf5737 100644 --- a/docs/thingstofix.md +++ b/docs/thingstofix.md @@ -1,11 +1,15 @@ -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. +# UI Cleanup Resolution + +All eleven findings from the original cleanup list are resolved in the current shelf design. + +1. [x] **Hover controls obscured clip text.** Hover now expands the preview visually; commands are exposed through the context menu and keyboard, so no hover rail covers content. +2. [x] **Hover broke keyboard navigation.** Hover state is independent from keyboard focus and selection, and arrow navigation clears stale hover ownership without changing the wrong clip. +3. [x] **Category changes felt abrupt.** Search, category, card, and panel changes use short eased transitions and become immediate when macOS Reduce Motion is enabled. +4. [x] **The collapsed search control was broken.** Search has one aligned container: it expands on click, typing, or `Command + F`, and collapses after click-away only when the query is empty. +5. [x] **Filtering was split across categories and a search menu.** Category chips are the primary visual filters; a click replaces the filter and Command-click builds a union. `Command + F` only focuses search. +6. [x] **Empty built-in categories added noise.** Built-in type/sort chips are created only when they have matches or are selected; empty custom Pinboards remain visible by design. +7. [x] **A nonfunctional resize lip was visible.** The shelf has no resize handle and uses its fixed side-shelf frame. +8. [x] **New Text Clip was unnecessary.** The panel, menu-bar menu, and shortcut map no longer expose a new-text action. +9. [x] **The alternate compact mode was unnecessary.** Settings exposes one Side Shelf layout; row sizing adapts automatically to available space instead of presenting a mode toggle. +10. [x] **The panel did not need a close control.** There is no close button in shelf chrome; `Esc` and the configured global shortcut dismiss it. +11. [x] **The persistent status bar consumed space.** The shelf flows directly from its toolbar into the side-rail card list; feedback is shown in the relevant menu or Settings page. diff --git a/sources/clipbored/app/AppDelegate.swift b/sources/clipbored/app/AppDelegate.swift index 615de9a..4365a10 100644 --- a/sources/clipbored/app/AppDelegate.swift +++ b/sources/clipbored/app/AppDelegate.swift @@ -44,7 +44,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var statusMenu: NSMenu? private var pauseResumeTimer: Timer? private var cloudSyncPushWorkItem: DispatchWorkItem? + private let cloudSyncOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "clipbored.cloud-sync" + queue.qualityOfService = .utility + queue.maxConcurrentOperationCount = 1 + return queue + }() + private let cloudSyncStateLock = NSLock() private var suppressCloudSyncPush = false + private var cloudSyncOperationGeneration = 0 func applicationDidFinishLaunching(_ notification: Notification) { settings = SettingsModel() @@ -57,7 +66,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { settings: settings, cacheService: cacheService, pollClipboardNow: { [weak monitor] in - monitor?.pollNowAndWait() + monitor?.pollNow() }, openSettings: { [weak self] in self?.openSettings() @@ -79,12 +88,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.panelController.toggle() } }, - onOpenSettings: { [weak self] in - DispatchQueue.main.async { - self?.refreshAccessibilityPermissionMessage() - self?.settingsController.show() - } - }, onToggleStackCapture: { [weak self] in DispatchQueue.main.async { self?.panelController.toggleStackCaptureMode() @@ -95,8 +98,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.settings.setShortcutStatus(message: status.message) } }, - openShortcut: settings.openShortcut, - settingsShortcut: settings.settingsShortcut + openShortcut: settings.openShortcut ) bindSettings() bindCloudSync() @@ -120,6 +122,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { pauseResumeTimer?.invalidate() cloudSyncPushWorkItem?.cancel() + cloudSyncOperationQueue.cancelAllOperations() monitor.stop() shortcutManager.stop() cacheService.clearTemporaryPreviews(wait: true) @@ -571,7 +574,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { observedInitialItems = true return } - guard self.settings.iCloudSyncEnabled, !self.suppressCloudSyncPush else { return } + guard self.settings.iCloudSyncEnabled, !self.isCloudSyncPushSuppressed else { return } self.scheduleCloudSyncPush() } @@ -590,22 +593,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { store.normalizeHistoryLength() case .imageCacheMaxBytes: cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes) - case .openShortcut, .settingsShortcut: - let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut, settingsShortcut: settings.settingsShortcut) + case .openShortcut: + let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut) settings.setShortcutStatus(message: status.message) refreshStatusItem() configureMainMenu() + case .settingsShortcut: + refreshStatusItem() + configureMainMenu() case .launchAtLogin: applyLaunchAtLoginSetting(settings.launchAtLogin) case .showMenuBarIcon: applyPresentation(changedSurface: .menuBar) case .showDockIcon: applyPresentation(changedSurface: .dock) - case .compactMode: - break - case .panelLayout: - break - case .panelSizing: + case .panelSide: break case .cloudSync: applyCloudSyncSetting() @@ -627,30 +629,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func applyCloudSyncSetting() { cloudSyncPushWorkItem?.cancel() cloudSyncPushWorkItem = nil + cloudSyncOperationGeneration += 1 + let generation = cloudSyncOperationGeneration guard settings.iCloudSyncEnabled else { + setCloudSyncPushSuppressed(false) 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)") + settings.setCloudSyncStatus(message: "Checking iCloud Sync…") + setCloudSyncPushSuppressed(true) + cloudSyncOperationQueue.addOperation { [weak self] in + guard let self else { return } + let status = self.cloudSyncService.status() + let result: (message: String, schedulesInitialPush: Bool) + if !status.isAvailable { + result = (status.message, false) + } else { + do { + let summary = try self.cloudSyncService.pull(store: self.store) + result = ("Restored \(summary.itemCount) clips from iCloud.", false) + } catch ClipboardCloudSyncError.noRemoteArchive(_) { + result = ("iCloud Sync is ready. No remote archive yet.", true) + } catch { + result = ("iCloud Sync failed: \(error.localizedDescription)", false) + } + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.setCloudSyncPushSuppressed(false) + guard generation == self.cloudSyncOperationGeneration, + self.settings.iCloudSyncEnabled else { return } + self.settings.setCloudSyncStatus(message: result.message) + if result.schedulesInitialPush { + self.scheduleCloudSyncPush(after: 1.0) + } + } } } @@ -665,14 +680,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { 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)") + let generation = cloudSyncOperationGeneration + cloudSyncOperationQueue.addOperation { [weak self] in + guard let self else { return } + let message: String + do { + let summary = try self.cloudSyncService.push(store: self.store) + message = "Synced \(summary.itemCount) clips to iCloud." + } catch { + message = "iCloud Sync failed: \(error.localizedDescription)" + } + DispatchQueue.main.async { [weak self] in + guard let self, + generation == self.cloudSyncOperationGeneration, + self.settings.iCloudSyncEnabled else { return } + self.settings.setCloudSyncStatus(message: message) + } } } + private var isCloudSyncPushSuppressed: Bool { + cloudSyncStateLock.lock() + defer { cloudSyncStateLock.unlock() } + return suppressCloudSyncPush + } + + private func setCloudSyncPushSuppressed(_ suppressed: Bool) { + cloudSyncStateLock.lock() + suppressCloudSyncPush = suppressed + cloudSyncStateLock.unlock() + } + private func applyCapturePauseSetting(now: Date = Date()) { if Self.shouldResumeExpiredCapturePause( isCapturePaused: settings.pauseCapture, diff --git a/sources/clipbored/models/SettingsModel.swift b/sources/clipbored/models/SettingsModel.swift index d1df1f3..5b904d2 100644 --- a/sources/clipbored/models/SettingsModel.swift +++ b/sources/clipbored/models/SettingsModel.swift @@ -25,15 +25,6 @@ enum HistoryRetention: Int { } } -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 @@ -59,9 +50,7 @@ final class SettingsModel { case launchAtLogin case showMenuBarIcon case showDockIcon - case compactMode - case panelLayout - case panelSizing + case panelSide case cloudSync case pauseCapture case ignoredApps @@ -87,10 +76,7 @@ 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" @@ -142,22 +128,8 @@ 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) } } + didSet { if oldValue != panelSide { storeAndNotify(.panelSide) } } } var iCloudSyncEnabled: Bool { didSet { @@ -241,10 +213,7 @@ 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 @@ -271,10 +240,6 @@ final class SettingsModel { maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems) imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes) - if panelShelfHeight < 0 { - panelShelfHeight = 0 - } - if defaults.object(forKey: Keys.maxHistoryItems) == nil || savedHistory != maxHistoryItems || defaults.object(forKey: Keys.historyRetention) == nil @@ -282,7 +247,6 @@ final class SettingsModel { || 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() @@ -299,10 +263,7 @@ 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) diff --git a/sources/clipbored/services/ShortcutManager.swift b/sources/clipbored/services/ShortcutManager.swift index 5ec13a2..aec29cf 100644 --- a/sources/clipbored/services/ShortcutManager.swift +++ b/sources/clipbored/services/ShortcutManager.swift @@ -24,36 +24,28 @@ 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 + openShortcut: ShortcutBinding ) { self.onOpenClipboardPanel = onOpenClipboardPanel - self.onOpenSettings = onOpenSettings self.onToggleStackCapture = onToggleStackCapture self.onStatusChange = onStatusChange self.openBinding = openShortcut - self.settingsBinding = settingsShortcut } deinit { @@ -64,16 +56,11 @@ final class ShortcutManager { func start() -> RegistrationStatus { stop() - if let status = Self.validationFailure(for: openBinding) ?? Self.validationFailure(for: settingsBinding) { + if let status = Self.validationFailure(for: openBinding) { onStatusChange(status) return status } - if openBinding == settingsBinding { - let status = RegistrationStatus.conflict(openBinding.displayText) - onStatusChange(status) - return status - } - if openBinding == Self.stackCaptureShortcut || settingsBinding == Self.stackCaptureShortcut { + if openBinding == Self.stackCaptureShortcut { let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText) onStatusChange(status) return status @@ -107,13 +94,6 @@ final class ShortcutManager { return openStatus } - let settingsStatus = register(binding: settingsBinding, id: .openSettings, target: &settingsHotKey) - guard settingsStatus == .registered else { - stop() - onStatusChange(settingsStatus) - return settingsStatus - } - let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey) guard stackCaptureStatus == .registered else { stop() @@ -126,9 +106,8 @@ final class ShortcutManager { } @discardableResult - func reconfigure(openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding) -> RegistrationStatus { + func reconfigure(openShortcut: ShortcutBinding) -> RegistrationStatus { openBinding = openShortcut - settingsBinding = settingsShortcut return start() } @@ -136,9 +115,6 @@ final class ShortcutManager { if let openHotKey { UnregisterEventHotKey(openHotKey) } - if let settingsHotKey { - UnregisterEventHotKey(settingsHotKey) - } if let stackCaptureHotKey { UnregisterEventHotKey(stackCaptureHotKey) } @@ -147,7 +123,6 @@ final class ShortcutManager { } openHotKey = nil - settingsHotKey = nil stackCaptureHotKey = nil eventHandler = nil } @@ -206,8 +181,6 @@ final class ShortcutManager { switch HotKeyID(rawValue: hotKeyID.id) { case .openPanel: onOpenClipboardPanel() - case .openSettings: - onOpenSettings() case .stackCapture: onToggleStackCapture() case nil: @@ -288,6 +261,10 @@ final class ShortcutManager { modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue ) + static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] { + [openShortcut, stackCaptureShortcut] + } + private func osStatusMessage(_ status: OSStatus) -> String { "OSStatus \(status)" } diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 9461e4d..d497225 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -59,14 +59,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel static let hideDuration: TimeInterval = 0.16 static let reflowDuration: TimeInterval = 0.18 static let easing: CAMediaTimingFunctionName = .easeInEaseOut + + static func duration(_ preferredDuration: TimeInterval) -> TimeInterval { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : preferredDuration + } } private enum Metrics { - 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 @@ -170,7 +168,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel applyPanelSharingSetting() settings.observe { [weak self] change in - guard change == .hideFromScreenCapture || change == .panelLayout else { return } + guard change == .hideFromScreenCapture || change == .panelSide else { return } DispatchQueue.main.async { if change == .hideFromScreenCapture { self?.applyPanelSharingSetting() @@ -205,13 +203,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } } - 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 } @@ -256,7 +247,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel panelView.beginOpeningTransition() NSAnimationContext.runAnimationGroup { context in - context.duration = Animation.showDuration + context.duration = Animation.duration(Animation.showDuration) context.allowsImplicitAnimation = true context.timingFunction = CAMediaTimingFunction(name: Animation.easing) panel.animator().setFrame(frames.shown, display: true) @@ -299,7 +290,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel isAnimating = true NSAnimationContext.runAnimationGroup { context in - context.duration = Animation.hideDuration + context.duration = Animation.duration(Animation.hideDuration) context.allowsImplicitAnimation = true context.timingFunction = CAMediaTimingFunction(name: Animation.easing) panel.animator().alphaValue = 0.0 @@ -323,7 +314,9 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel } show() - DispatchQueue.main.asyncAfter(deadline: .now() + Animation.showDuration + 0.03) { [weak self] in + let animationDelay = Animation.duration(Animation.showDuration) + let deadline = DispatchTime.now() + animationDelay + (animationDelay > 0 ? 0.03 : 0) + DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in guard let self, self.isVisible else { return } action() } @@ -374,7 +367,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel static func panelFrames( forScreenFrame screenFrame: CGRect, visibleFrame: CGRect, - preferredHeight _: CGFloat? = nil, side: ClipboardPanelSide = .right ) -> (shown: NSRect, hidden: NSRect) { let intersectedFrame = visibleFrame.intersection(screenFrame) @@ -454,7 +446,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel static func reflowPlan( forScreenFrame screenFrame: CGRect, visibleFrame: CGRect, - preferredHeight _: CGFloat? = nil, side: ClipboardPanelSide = .right ) -> ClipboardPanelReflowPlan { let frames = panelFrames( @@ -526,6 +517,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel self.viewModel.sortMode = mode return nil } + if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), + Self.matchesShortcut( + keyCode: event.keyCode, + modifiers: event.modifierFlags, + binding: self.settings.settingsShortcut + ) { + self.openSettings() + return nil + } if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true), let action = Self.commandShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) { self.performShortcutAction(action) @@ -624,7 +624,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel case .edit: panelView.editSelectedClip() case .focusSearch: - panelView.focusSearchOrShowFilters() + panelView.focusSearch() case .newCollection: panelView.createCollection() case .nextCollection: @@ -775,6 +775,18 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel return keyCode == 49 && relevantModifiers.isEmpty && searchText.clipboardTrimmed.isEmpty } + static func matchesShortcut( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + binding: ShortcutBinding + ) -> Bool { + guard ShortcutManager.virtualKeyCode(for: binding.key) == keyCode else { return false } + let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) + let bindingModifiers = NSEvent.ModifierFlags(rawValue: binding.modifierFlags) + .intersection(.deviceIndependentFlagsMask) + return relevantModifiers == bindingModifiers + } + static func commandShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? { let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask) guard relevantModifiers == .command else { return nil } @@ -924,7 +936,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel isAnimating = true NSAnimationContext.runAnimationGroup { context in - context.duration = Animation.reflowDuration + context.duration = Animation.duration(Animation.reflowDuration) context.allowsImplicitAnimation = true context.timingFunction = CAMediaTimingFunction(name: Animation.easing) panel.animator().setFrame(plan.frame, display: true) diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 6033bb6..d303a8e 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -7,9 +7,6 @@ private struct ClipboardItemCardLayout: Equatable { let headerHeight: CGFloat let bodyHeight: CGFloat let footerHeight: CGFloat - let actionButtonSize: CGFloat - let primaryActionButtonSize: CGFloat - let actionRailHeight: CGFloat let activeLift: CGFloat static let regular = ClipboardItemCardLayout( @@ -19,9 +16,6 @@ private struct ClipboardItemCardLayout: Equatable { headerHeight: 56, bodyHeight: 152, footerHeight: 36, - actionButtonSize: 24, - primaryActionButtonSize: 30, - actionRailHeight: 34, activeLift: 0 ) @@ -32,9 +26,6 @@ private struct ClipboardItemCardLayout: Equatable { headerHeight: 50, bodyHeight: 138, footerHeight: 32, - actionButtonSize: 22, - primaryActionButtonSize: 28, - actionRailHeight: 32, activeLift: 0 ) @@ -45,9 +36,6 @@ private struct ClipboardItemCardLayout: Equatable { headerHeight: 58, bodyHeight: 218, footerHeight: 44, - actionButtonSize: 26, - primaryActionButtonSize: 32, - actionRailHeight: 36, activeLift: 0 ) @@ -67,9 +55,6 @@ private struct ClipboardItemCardLayout: Equatable { headerHeight: headerHeight, bodyHeight: bodyHeight, footerHeight: footerHeight, - actionButtonSize: actionButtonSize, - primaryActionButtonSize: primaryActionButtonSize, - actionRailHeight: actionRailHeight, activeLift: activeLift ) } @@ -194,15 +179,40 @@ private struct ClipboardItemCardReuseFingerprint: Equatable { let quickPasteNumber: Int? } -#if DEBUG -private struct ClipboardSearchFilterPart { - let raw: String - let canonical: String - let canonicalKey: String? - let keyValueCanonical: String? - let isExact: Bool +private final class ClipboardPanelSearchFieldCell: NSSearchFieldCell { + private enum Metrics { + static let textLeadingInset: CGFloat = 40 + static let textTrailingInset: CGFloat = 8 + static let cancelButtonInset: CGFloat = 6 + static let cancelButtonSize: CGFloat = 18 + } + + override func searchButtonRect(forBounds rect: NSRect) -> NSRect { + .zero + } + + override func cancelButtonRect(forBounds rect: NSRect) -> NSRect { + guard cancelButtonCell != nil else { return .zero } + return NSRect( + x: rect.maxX - Metrics.cancelButtonInset - Metrics.cancelButtonSize, + y: floor(rect.midY - (Metrics.cancelButtonSize / 2)), + width: Metrics.cancelButtonSize, + height: Metrics.cancelButtonSize + ) + } + + override func searchTextRect(forBounds rect: NSRect) -> NSRect { + let trailingInset = stringValue.isEmpty + ? Metrics.textTrailingInset + : Metrics.cancelButtonInset + Metrics.cancelButtonSize + 4 + return NSRect( + x: rect.minX + Metrics.textLeadingInset, + y: rect.minY, + width: max(0, rect.width - Metrics.textLeadingInset - trailingInset), + height: rect.height + ) + } } -#endif private final class ClipboardPanelSearchField: NSSearchField { var onFocusStateChanged: (() -> Void)? @@ -235,31 +245,15 @@ private final class ClipboardPanelSearchField: NSSearchField { } 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 + self == .verticalFocus } 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) @@ -273,38 +267,39 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private enum Metrics { static let actionButtonSize: CGFloat = 28 static let searchControlSize: CGFloat = 30 - static let panelTopInset: CGFloat = 6 + static let panelTopInset: CGFloat = 10 static let panelSideInset: CGFloat = 12 static let actionBarHorizontalPadding: CGFloat = 10 - static let panelStatusBarHeight: CGFloat = 24 static let minimumBottomInset: CGFloat = 20 - static let panelCornerRadius: CGFloat = 22 - static let compactCardThreshold: CGFloat = 760 - static let emptyStateMinimumWidth: CGFloat = 760 - static let collectionRailHeight: CGFloat = 32 - static let collectionRailMaximumWidth: CGFloat = 760 - static let verticalCollectionRailMaximumWidth: CGFloat = 260 + static let panelCornerRadius: CGFloat = 14 + static let collectionRailHeight: CGFloat = 34 + static let collectionUtilitySize: CGFloat = 34 + static let collectionChipScrollPadding: CGFloat = 4 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 + static let maximumExpandedSearchWidth: CGFloat = 240 + static let searchActionGap: CGFloat = 12 } fileprivate enum Motion { - static let contentSwitchDuration: TimeInterval = 0.16 - static let contentSwitchStartAlpha: CGFloat = 0.68 - static let searchFieldResizeDuration: TimeInterval = 0.14 + static let contentSwitchDuration: TimeInterval = 0.14 + static let contentSwitchStartAlpha: CGFloat = 0.78 + static let searchFieldResizeDuration: TimeInterval = 0.20 static let cardLiftDuration: TimeInterval = 0.16 - static let cardExpansionDuration: TimeInterval = 0.36 - static let actionRailDuration: TimeInterval = 0.14 + static let cardExpansionDuration: TimeInterval = 0.22 static var cardExpansionTiming: CAMediaTimingFunction { - CAMediaTimingFunction(controlPoints: 0.25, 0.10, 0.25, 1.00) + CAMediaTimingFunction(controlPoints: 0.20, 0.80, 0.20, 1.00) + } + + static var reducesMotion: Bool { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } + + static func duration(_ preferredDuration: TimeInterval) -> TimeInterval { + reducesMotion ? 0 : preferredDuration } } @@ -313,57 +308,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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, - 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 - } + .compact } var cardSpacing: CGFloat { - switch self { - case .regular: return 16 - case .compact: return 12 - case .expanded: return 18 - } + 12 } var cardStackInset: CGFloat { - switch self { - case .regular: return 10 - case .compact: return 8 - case .expanded: return 12 - } + 8 } var cardTopInset: CGFloat { @@ -375,39 +332,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var railHeight: CGFloat { - 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 - } + let itemHeight = ClipboardCardPresentation.verticalRow.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.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 { - case ready - case action - case warning - case error - case neutral + static let searchGlassBorder = NSColor.white.withAlphaComponent(0.42).cgColor + static let searchGlassTint = NSColor.windowBackgroundColor.withAlphaComponent(0.10).cgColor } private let viewModel: ClipboardPanelViewModel @@ -416,9 +351,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let onPreview: () -> Void private let searchField = ClipboardPanelSearchField() - private let searchControlContainer = NSView() + private let searchControlContainer = NSVisualEffectView() private let searchIconButton = NSButton() - private weak var categoryMenuButton: NSButton? private weak var clearHistoryButton: NSButton? private weak var settingsButton: NSButton? private weak var utilityToolbarGroup: NSView? @@ -433,11 +367,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 cardDensity: CardDensity = .compact private var scrollViewHeightConstraint: NSLayoutConstraint? private var searchFieldWidthConstraint: NSLayoutConstraint? private var collectionScrollWidthConstraint: NSLayoutConstraint? @@ -469,12 +399,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 + private var debugItemReloadCountValue = 0 #endif private var defersVisualReloads = false private var pendingItemReload = false @@ -505,7 +433,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { #if DEBUG private var collectionNameProviderForTesting: (() -> String?)? - private var textClipProviderForTesting: (() -> String?)? private var suppressClipEditDialogsForTesting = false private var renameClipRequestCountForTesting = 0 private var editClipRequestCountForTesting = 0 @@ -529,8 +456,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { bindViewModel() reloadItems() updateSelection() - updateStatus(viewModel.statusMessage) - updateResultCount() updateCollectionButtons() } @@ -545,16 +470,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { isEmphasized = true wantsLayer = true layer?.cornerRadius = Metrics.panelCornerRadius - layer?.masksToBounds = false + layer?.masksToBounds = true layer?.backgroundColor = Palette.panelSurface layer?.borderWidth = 0.8 layer?.borderColor = Palette.panelBorder layer?.shadowColor = Palette.panelShadow - layer?.shadowOpacity = 0.28 - layer?.shadowRadius = 34 - layer?.shadowOffset = NSSize(width: 0, height: 14) + layer?.shadowOpacity = 0 + layer?.shadowRadius = 0 + layer?.shadowOffset = .zero configureSearchIconButton() + searchField.cell = ClipboardPanelSearchFieldCell(textCell: "") + searchField.isEditable = true + searchField.isSelectable = true searchField.setAccessibilityLabel("Search clipboard history") searchField.delegate = self searchField.target = self @@ -564,18 +492,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } searchField.sendsSearchStringImmediately = true searchField.sendsWholeSearchString = false - searchField.isBezeled = true + searchField.isBezeled = false + searchField.drawsBackground = false searchField.placeholderAttributedString = NSAttributedString( string: "Search clips", attributes: [ .foregroundColor: NSColor.tertiaryLabelColor ] ) - searchField.bezelStyle = .roundedBezel - searchField.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.68) + searchField.backgroundColor = .clear + searchField.font = .systemFont(ofSize: 13, weight: .regular) searchField.focusRingType = .none searchField.toolTip = "Search clipboard history." - searchField.setAccessibilityHelp("Type to search clipboard history. Use the side category icons to filter results.") + searchField.setAccessibilityHelp("Type to search clipboard history. Use the category icons beside the cards to filter results; Command-click combines categories.") searchField.translatesAutoresizingMaskIntoConstraints = false searchField.setContentCompressionResistancePriority(.required, for: .horizontal) searchField.setContentHuggingPriority(.required, for: .horizontal) @@ -738,7 +667,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { applyPanelLayout(reloadItems: false) updateCollectionButtons() - updateResultCount() } private func bindViewModel() { @@ -753,10 +681,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } viewModel.onSelectedItemsChanged = { [weak self] in self?.updateSelection() - self?.updateResultCount() - } - viewModel.onStatusMessageChanged = { [weak self] message in - self?.updateStatus(message) } viewModel.onSortModeChanged = { [weak self] _ in self?.updateCollectionButtons() @@ -767,16 +691,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.onStackChanged = { [weak self] in 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 { @@ -818,9 +732,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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.layer?.borderWidth = 0 + searchIconButton.layer?.borderColor = NSColor.clear.cgColor + searchIconButton.layer?.backgroundColor = NSColor.clear.cgColor searchIconButton.contentTintColor = .secondaryLabelColor searchIconButton.toolTip = toolTip searchIconButton.target = self @@ -834,8 +748,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func configureSearchControlContainer() { searchControlContainer.translatesAutoresizingMaskIntoConstraints = false + searchControlContainer.material = .popover + searchControlContainer.blendingMode = .withinWindow + searchControlContainer.state = .active + searchControlContainer.isEmphasized = true searchControlContainer.wantsLayer = true + searchControlContainer.layer?.cornerRadius = Metrics.searchControlSize / 2 + searchControlContainer.layer?.cornerCurve = .continuous searchControlContainer.layer?.masksToBounds = true + searchControlContainer.layer?.backgroundColor = Palette.searchGlassTint + searchControlContainer.layer?.borderWidth = 0.7 + searchControlContainer.layer?.borderColor = Palette.searchGlassBorder searchControlContainer.setContentHuggingPriority(.required, for: .horizontal) searchControlContainer.setContentCompressionResistancePriority(.required, for: .horizontal) searchFieldWidthConstraint = searchControlContainer.widthAnchor.constraint(equalToConstant: Metrics.searchControlSize) @@ -847,465 +770,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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), + searchField.centerYAnchor.constraint(equalTo: searchControlContainer.centerYAnchor), + searchField.heightAnchor.constraint(equalTo: searchControlContainer.heightAnchor), searchIconButton.leadingAnchor.constraint(equalTo: searchControlContainer.leadingAnchor), - searchIconButton.topAnchor.constraint(equalTo: searchControlContainer.topAnchor), - searchIconButton.bottomAnchor.constraint(equalTo: searchControlContainer.bottomAnchor) + searchIconButton.centerYAnchor.constraint(equalTo: searchControlContainer.centerYAnchor) ]) } - #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: "") + let item = NSMenuItem(title: title, action: #selector(performClearHistoryMenuItem(_:)), keyEquivalent: "") item.target = self item.representedObject = seconds menu.addItem(item) @@ -1336,13 +813,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.toolTip = mode.title 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 @@ -1355,13 +827,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.toolTip = 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) } @@ -1390,13 +858,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.toolTip = "Queued clips" 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() } @@ -1427,56 +891,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } } - 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) @@ -1488,11 +902,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.moveSelectionFromFocusedCard(delta) } chip.onSelectFirst = { [weak self] in - self?.selectCollectionChip(at: 0) + self?.focusCollectionChip(at: 0) } chip.onSelectLast = { [weak self] in guard let self else { return } - self.selectCollectionChip(at: self.collectionChipOrder.count - 1) + self.focusCollectionChip(at: self.collectionChipOrder.count - 1) } } @@ -1500,33 +914,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 @@ -1535,7 +922,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addCollectionButton.imageScaling = .scaleProportionallyDown addCollectionButton.isBordered = false addCollectionButton.wantsLayer = true - addCollectionButton.layer?.cornerRadius = Metrics.collectionSideRailItemSize / 2 + addCollectionButton.layer?.cornerRadius = Metrics.collectionUtilitySize / 2 addCollectionButton.layer?.borderWidth = 0.5 addCollectionButton.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.10).cgColor addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.18).cgColor @@ -1546,8 +933,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { addCollectionButton.target = self addCollectionButton.action = #selector(createCollectionFromToolbar) addCollectionButton.translatesAutoresizingMaskIntoConstraints = false - addCollectionButton.widthAnchor.constraint(equalToConstant: Metrics.collectionSideRailItemSize).isActive = true - addCollectionButton.heightAnchor.constraint(equalToConstant: Metrics.collectionSideRailItemSize).isActive = true + addCollectionButton.widthAnchor.constraint(equalToConstant: Metrics.collectionUtilitySize).isActive = true + addCollectionButton.heightAnchor.constraint(equalToConstant: Metrics.collectionUtilitySize).isActive = true } private func collectionTitle(for mode: ClipboardSortMode) -> String { @@ -1603,19 +990,19 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 } + let availableCardWidth = floor( + visibleWidth - verticalCardLeadingReserve - (cardDensity.cardStackInset * 2) + ) + guard availableCardWidth > 0 else { return layout } return layout.withWidth(max(Metrics.minimumVerticalCardWidth, availableCardWidth)) } private var verticalCardLeadingReserve: CGFloat { - currentPanelLayout == .vertical ? Metrics.collectionSideRailWidth : 0 + Metrics.collectionSideRailWidth + Metrics.collectionRailCardClearance } private func verticalCardMinXInContent(width cardWidth: CGFloat, viewportWidth: CGFloat) -> CGFloat { @@ -1625,19 +1012,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } 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) + updateShelfChromeLayout() + itemsStack.orientation = .vertical + itemsStack.usesFlippedCoordinates = true + itemsStack.alignment = .centerX + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollViewHeightConstraint?.isActive = false + bottomSpacer.isHidden = true + scrollView.setContentHuggingPriority(.defaultLow, for: .vertical) + scrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) lastScrollViewportSize = .zero updateSearchFieldPresentation() @@ -1645,16 +1029,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { applyCardDensity() if shouldReload { - let densityChanged = updateCardDensityForCurrentWidth() - if !densityChanged { - reloadItems() - } + reloadItems() updateSelection() } needsLayout = true } - private func updateShelfChromeLayout(isVertical: Bool) { + private func updateShelfChromeLayout() { shelfChromeHeightConstraint?.constant = Metrics.collectionRailHeight searchControlCenterYConstraint?.constant = 0 utilityToolbarCenterYConstraint?.constant = 0 @@ -1663,22 +1044,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { contentStack?.needsLayout = true } - @discardableResult - private func updateCardDensityForCurrentWidth() -> Bool { - let targetDensity = CardDensity.fitting( - width: bounds.width, - height: bounds.height, - compactModeEnabled: viewModel.isCompactModeEnabled, - panelLayout: currentPanelLayout - ) - guard targetDensity != cardDensity else { return false } - - cardDensity = targetDensity - applyCardDensity() - reloadItems() - return true - } - private func contentInsets() -> NSEdgeInsets { NSEdgeInsets( top: Metrics.panelTopInset, @@ -1703,9 +1068,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { button.bezelStyle = .smallSquare button.isBordered = false button.wantsLayer = true - button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor + button.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.06).cgColor button.layer?.borderWidth = 0.5 - button.layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor + button.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor button.layer?.cornerRadius = Metrics.actionButtonSize / 2 button.toolTip = toolTip button.contentTintColor = .secondaryLabelColor @@ -1762,6 +1127,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func reloadItems() { + #if DEBUG + debugItemReloadCountValue += 1 + #endif cardReloadGeneration += 1 let reloadGeneration = cardReloadGeneration let focusedCardReloadTarget = focusedCardReloadTarget() @@ -1813,8 +1181,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { updateSelection() restoreFocusedCardIfNeeded(focusedCardReloadTarget) - updateStatus(viewModel.statusMessage) - updateResultCount() animateContentReloadIfNeeded(shouldAnimateReload) } @@ -1840,47 +1206,57 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 + let startPosition = visibleBounds.minY + let endPosition = visibleBounds.maxY + let leadingInset = edgeInsets.top - 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.. Int { + var lowerBound = 0 + var upperBound = itemCount + while lowerBound < upperBound { + let index = (lowerBound + upperBound) / 2 + let end = cardSlotOffset(at: index, layout: layout) + cardSlotPrimaryLength(at: index, layout: layout) + if end < position { + lowerBound = index + 1 + } else { + upperBound = index + } + } + return min(max(0, lowerBound), max(0, itemCount - 1)) + } + + private func lastCardIndex( + startingAtOrBefore position: CGFloat, + itemCount: Int, + layout: ClipboardItemCardLayout + ) -> Int { + var lowerBound = 0 + var upperBound = itemCount + while lowerBound < upperBound { + let index = (lowerBound + upperBound) / 2 + if cardSlotOffset(at: index, layout: layout) <= position { + lowerBound = index + 1 + } else { + upperBound = index + } + } + return min(max(0, lowerBound - 1), max(0, itemCount - 1)) + } + private func renderCards(in range: Range, reloadGeneration: Int) { guard let context = currentCardRenderContext, context.reloadGeneration == reloadGeneration, @@ -1897,37 +1273,77 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } let item = context.items[index] - let fingerprint = cardReuseFingerprint( - for: item, - index: index, - layout: context.layout, - collectionNames: context.collectionNames, - selectedCollectionName: context.selectedCollectionName, - selectedCollectionColor: context.selectedCollectionColor, - canShowInClipboard: context.canShowInClipboard - ) - let card = ClipboardItemCardView( - item: item, - thumbnail: viewModel.thumbnail(for: item), - index: index, - reuseFingerprint: fingerprint, - layout: context.layout, - collectionNames: context.collectionNames, - isStacked: viewModel.isItemStacked(at: index), - stackCount: viewModel.stackCount, - canShowInClipboard: context.canShowInClipboard, - selectedCollectionName: context.selectedCollectionName, - selectedCollectionColor: context.selectedCollectionColor - ) + let card = makeCard(item: item, thumbnail: nil, index: index, context: context) let slot = cardSlot(at: index, itemID: item.id, layout: context.layout) slot.attachCard(card) configureCardCallbacks(card, slot: slot) applyCurrentCardState(card, index: index) + loadThumbnail(for: item, at: index, reloadGeneration: reloadGeneration) } cardViews = renderedCardsInItemOrder() } + private func makeCard( + item: ClipboardItem, + thumbnail: NSImage?, + index: Int, + context: CardRenderContext + ) -> ClipboardItemCardView { + let fingerprint = cardReuseFingerprint( + for: item, + index: index, + layout: context.layout, + collectionNames: context.collectionNames, + selectedCollectionName: context.selectedCollectionName, + selectedCollectionColor: context.selectedCollectionColor, + canShowInClipboard: context.canShowInClipboard + ) + return ClipboardItemCardView( + item: item, + thumbnail: thumbnail, + index: index, + reuseFingerprint: fingerprint, + layout: context.layout, + collectionNames: context.collectionNames, + isStacked: viewModel.isItemStacked(at: index), + stackCount: viewModel.stackCount, + canShowInClipboard: context.canShowInClipboard, + selectedCollectionName: context.selectedCollectionName, + selectedCollectionColor: context.selectedCollectionColor + ) + } + + private func loadThumbnail(for item: ClipboardItem, at index: Int, reloadGeneration: Int) { + viewModel.loadThumbnail(for: item) { [weak self] thumbnail in + guard let self, let thumbnail else { return } + guard self.cardReloadGeneration == reloadGeneration, + let context = self.currentCardRenderContext, + context.reloadGeneration == reloadGeneration, + context.items.indices.contains(index), + context.items[index].id == item.id, + let slot = self.cardSlots[index], + slot.representedItemID == item.id, + slot.card != nil else { + return + } + + let existingCard = slot.card + let responder = self.window?.firstResponder as? NSView + let restoresFocus = existingCard.map { card in + responder === card || responder?.isDescendant(of: card) == true + } ?? false + let replacement = self.makeCard(item: item, thumbnail: thumbnail, index: index, context: context) + slot.attachCard(replacement) + self.configureCardCallbacks(replacement, slot: slot) + self.applyCurrentCardState(replacement, index: index) + self.cardViews = self.renderedCardsInItemOrder() + if restoresFocus { + self.window?.makeFirstResponder(replacement) + } + } + } + private func renderCardIfNeeded(at index: Int) { guard index >= 0, index < cardItemCount else { return } renderCards(in: index..<(index + 1), reloadGeneration: cardReloadGeneration) @@ -2002,7 +1418,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } NSAnimationContext.runAnimationGroup { context in - context.duration = Motion.cardExpansionDuration + context.duration = Motion.duration(Motion.cardExpansionDuration) context.timingFunction = Motion.cardExpansionTiming for (index, slot) in cardSlots { slot.animator().frame = cardSlotFrame(at: index, layout: layout) @@ -2020,52 +1436,35 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 + 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) } 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 + index == visuallyExpandedCardIndex ? .verticalFocus : .verticalRow } private var visuallyExpandedCardIndex: Int? { - if currentPanelLayout == .vertical { - if let hoveredCardIndex { - return hoveredCardIndex - } - return cardSelectionInputSource == .keyboard ? viewModel.selectedIndex : nil + if let hoveredCardIndex { + return hoveredCardIndex } - return viewModel.selectedIndex + return cardSelectionInputSource == .keyboard ? viewModel.selectedIndex : nil } private func cardSlotPrimaryLength(at index: Int, layout: ClipboardItemCardLayout) -> CGFloat { - let size = cardPresentation(at: index).size(for: layout) - return currentPanelLayout == .vertical ? size.height : size.width + cardPresentation(at: index).size(for: layout).height } private func cardSlotOffset(at targetIndex: Int, layout: ClipboardItemCardLayout) -> CGFloat { guard targetIndex > 0 else { return 0 } - var offset: CGFloat = 0 - for index in 0..= 0, + expandedIndex < targetIndex { + let expandedLength = cardSlotPrimaryLength(at: expandedIndex, layout: layout) + offset += expandedLength - baseLength } return offset } @@ -2092,18 +1491,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.collapseSearchFieldIfIdle() self?.viewModel.selectItem(at: selected, mode: mode) } - card.onHover = { [weak self, weak card] selected, mouseLocation in + card.onHover = { [weak self] selected, mouseLocation in guard let self else { return false } - guard let hoverIndex = self.claimMouseHoverSelection(at: selected, mouseLocation: mouseLocation) else { + guard let hoverIndex = self.claimMouseHover(at: selected, mouseLocation: mouseLocation) else { return false } - self.viewModel.selectItem(at: hoverIndex, mode: .hover) - if !self.isSearchFieldEditing { - let focusedCard = self.cardView(at: hoverIndex) ?? (hoverIndex == selected ? card : nil) - if let focusedCard { - self.window?.makeFirstResponder(focusedCard) - } - } return hoverIndex == selected } card.onHoverExit = { [weak self] index in @@ -2277,10 +1669,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func animateContentReloadIfNeeded(_ shouldAnimate: Bool) { - guard shouldAnimate else { return } + guard shouldAnimate, !Motion.reducesMotion else { + scrollView.alphaValue = 1 + return + } scrollView.alphaValue = Motion.contentSwitchStartAlpha NSAnimationContext.runAnimationGroup { context in - context.duration = Motion.contentSwitchDuration + context.duration = Motion.duration(Motion.contentSwitchDuration) context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) scrollView.animator().alphaValue = 1.0 } @@ -2292,8 +1687,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { if collectionChipsNeedRebuild { pendingCollectionReload = true } - updateStatus(viewModel.statusMessage) - updateResultCount() return } @@ -2323,8 +1716,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func handleStackChanged() { if defersVisualReloads { pendingCollectionReload = true - updateStatus(viewModel.statusMessage) - updateResultCount() return } @@ -2336,8 +1727,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { updateVisibleCardStackState() updateSelection() updateCollectionButtons() - updateStatus(viewModel.statusMessage) - updateResultCount() if shouldRestoreCollectionFocus { focusSelectedCollectionChip() } @@ -2390,16 +1779,15 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } 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 + stableHeaderRollout: animated ) card.setStackState(isStacked: viewModel.isItemStacked(at: index), stackCount: viewModel.stackCount) card.setSelectionState( - active: isVisuallyExpanded || (currentPanelLayout != .vertical && index == viewModel.selectedIndex), + active: isVisuallyExpanded, selected: viewModel.isItemSelected(at: index), selectionCount: viewModel.selectedItemCount ) @@ -2445,9 +1833,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { itemsStack.layoutSubtreeIfNeeded() 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) + let paddedFrame = frame.insetBy(dx: 0, dy: -cardDensity.cardSpacing) itemsStack.scrollToVisible(paddedFrame) scrollView.reflectScrolledClipView(scrollView.contentView) } @@ -2457,26 +1843,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { let currentIndex = collectionChipOrder.firstIndex(where: { $0 === chip }) else { return } - selectCollectionChip(at: currentIndex + delta) + focusCollectionChip(at: currentIndex + delta) } - private func selectCollectionChip(at index: Int) { + private func focusCollectionChip(at index: Int) { guard !collectionChipOrder.isEmpty else { return } let targetIndex = max(0, min(collectionChipOrder.count - 1, index)) - let title = collectionChipOrder[targetIndex].titleText - collectionChipOrder[targetIndex].onPress(false) - - let focusedChip: CollectionChipView? - if let rebuiltChip = collectionChipOrder.first(where: { $0.titleText == title }) { - focusedChip = rebuiltChip - } else if collectionChipOrder.indices.contains(targetIndex) { - focusedChip = collectionChipOrder[targetIndex] - } else { - focusedChip = nil - } - guard let focusedChip else { return } + let focusedChip = collectionChipOrder[targetIndex] collectionChipOrder.forEach { $0.clearKeyboardFocus() } window?.makeFirstResponder(focusedChip) + focusedChip.setKeyboardFocusForPresentation(true) scrollCollectionChipIntoView(focusedChip) } @@ -2514,75 +1890,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionStack.layoutSubtreeIfNeeded() let frame = chip.convert(chip.bounds, to: collectionDocumentView) - let paddedFrame = frame.insetBy(dx: -2, dy: -Metrics.collectionSideRailGap) + let paddedFrame = frame.insetBy(dx: -2, dy: -Metrics.collectionChipScrollPadding) collectionDocumentView.scrollToVisible(paddedFrame) collectionScrollView.reflectScrolledClipView(collectionScrollView.contentView) } - private func updateStatus(_ message: String) { - let text: String - if !message.isEmpty { - text = message - } else if !viewModel.captureStatusMessage.isEmpty { - text = viewModel.captureStatusMessage - } else if viewModel.visibleItems.isEmpty { - 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" - } - currentStatusText = text - currentStatusTone = statusTone(for: text) - } - - private func statusTone(for text: String) -> StatusTone { - let lower = text.lowercased() - if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") { - return .ready - } - if lower.hasPrefix("error") || lower.contains("failed") || lower.contains("could not") { - return .error - } - 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.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 - } - - private func updateResultCount() { - let count = viewModel.visibleItems.count - let noun = count == 1 ? "clip" : "clips" - let text: String - 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)" - } - currentResultCountText = text - } - private func editText(at index: Int) { viewModel.selectItem(at: index) guard let currentText = viewModel.editableTextForSelected() else { return } @@ -2648,16 +1960,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func emptyStateView() -> NSView { - let width = currentPanelLayout == .vertical - ? max(1, scrollView.contentView.bounds.width) - : max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width) + let width = max(1, 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) + let title = NSTextField(wrappingLabelWithString: copy.title) title.font = .systemFont(ofSize: 14, weight: .medium) title.textColor = .labelColor title.alignment = .center + title.lineBreakMode = .byWordWrapping + title.maximumNumberOfLines = 2 + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let detail = NSTextField(wrappingLabelWithString: copy.detail) detail.font = .systemFont(ofSize: NSFont.smallSystemFontSize) @@ -2675,7 +1988,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: container.centerXAnchor), stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), - stack.widthAnchor.constraint(lessThanOrEqualTo: container.widthAnchor, constant: -80) + stack.widthAnchor.constraint(lessThanOrEqualTo: container.widthAnchor, constant: -72), + stack.widthAnchor.constraint(lessThanOrEqualToConstant: 260) ]) return container } @@ -2685,19 +1999,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) - } + let contentHeight = verticalCardRailContentLength(itemCount: itemCount, layout: layout) + + (cardDensity.cardStackInset * 2) + let width = max(viewportSize.width, layout.width + (cardDensity.cardStackInset * 2)) + let height = max(viewportSize.height, contentHeight) lastScrollViewportSize = viewportSize let targetFrame = NSRect(x: 0, y: 0, width: width, height: height) guard animated, window != nil else { @@ -2713,7 +2018,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { #endif NSAnimationContext.runAnimationGroup { context in - context.duration = Motion.cardExpansionDuration + context.duration = Motion.duration(Motion.cardExpansionDuration) context.timingFunction = Motion.cardExpansionTiming itemsStack.animator().frame = targetFrame positionRenderedCardSlots(animated: true) @@ -2727,37 +2032,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } } - 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 + let baseHeight = ClipboardCardPresentation.verticalRow.size(for: layout).height + var length = CGFloat(itemCount) * baseHeight + CGFloat(max(0, itemCount - 1)) * cardDensity.cardSpacing + if let expandedIndex = visuallyExpandedCardIndex, + expandedIndex >= 0, + expandedIndex < itemCount { + length += cardPresentation(at: expandedIndex).size(for: layout).height - baseHeight } return length } @@ -2829,7 +2111,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { stackChip.setSelected(viewModel.isStackFilterSelected) stackChip.setStackCaptureActive(viewModel.isStackCaptureEnabled) stackChip.setCount(viewModel.stackCount) - categoryMenuButton?.isEnabled = !overflowSortModesForCurrentCounts().isEmpty sizeCollectionDocument() } @@ -2866,12 +2147,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { override func layout() { super.layout() - _ = updateCardDensityForCurrentWidth() updateCollectionRailHorizontalPosition() sizeCollectionDocument() guard !scrollView.frame.equalTo(.zero) else { return } let viewportSize = scrollView.contentView.bounds.size + if let currentCardRenderContext, + currentCardRenderContext.layout != currentCardLayout(viewportWidth: viewportSize.width) { + lastScrollViewportSize = viewportSize + reloadItems() + return + } if viewportSize == lastScrollViewportSize { return } @@ -2879,9 +2165,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) + let emptyStateWidth = max(1, scrollView.contentView.bounds.width) documentView.frame.size = NSSize( width: emptyStateWidth, height: max(cardDensity.railHeight, scrollView.contentView.bounds.height) @@ -2889,11 +2173,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return } - if let currentCardRenderContext, currentCardRenderContext.layout != currentCardLayout(viewportWidth: viewportSize.width) { - reloadItems() - return - } - sizeItemsDocument(itemCount: cardItemCount) renderCardsNearVisibleViewport() } @@ -2907,14 +2186,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } + 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() - } + 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 @@ -2922,11 +2195,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { || abs(currentInsets.right) > 0.5 { collectionStack.edgeInsets = NSEdgeInsetsZero } - let contentHeight = rawContentHeight - let documentHeight = max(viewportHeight, contentHeight) - let stackY = max(0, floor((documentHeight - contentHeight) / 2)) + let contentWidth = Metrics.collectionSideRailWidth + let documentHeight = max(viewportHeight, rawContentHeight) + let stackY = max(0, floor((documentHeight - rawContentHeight) / 2)) collectionDocumentView.frame = NSRect(x: 0, y: 0, width: contentWidth, height: documentHeight) - collectionStack.frame = NSRect(x: 0, y: stackY, width: contentWidth, height: contentHeight) + collectionStack.frame = NSRect(x: 0, y: stackY, width: contentWidth, height: rawContentHeight) let maxOffset = max(0, documentHeight - viewportHeight) let currentOrigin = collectionScrollView.contentView.bounds.origin let targetY: CGFloat @@ -2945,30 +2218,21 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } 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)) + let cardMinX = verticalCardMinXInContent(width: layout.width, viewportWidth: viewportWidth) + let desiredLeading = (cardMinX / 2) - (railWidth / 2) + let maximumLeading = cardMinX - railWidth - Metrics.collectionRailCardClearance + let targetLeading = max(0, min(desiredLeading, maximumLeading)) if abs((collectionRailLeadingConstraint?.constant ?? 0) - targetLeading) > 0.5 { collectionRailLeadingConstraint?.constant = targetLeading contentStack.layoutSubtreeIfNeeded() - collectionScrollView.layoutSubtreeIfNeeded() } } @@ -2978,15 +2242,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { || searchFieldPresentationRequested || searchField.hasKeyboardFocusForPresentation || (isSearchFieldEditing && !searchFieldCollapsedWhileIdle) - let expandedWidth: CGFloat = currentPanelLayout == .vertical - ? Metrics.verticalExpandedSearchWidth - : Metrics.expandedSearchWidth + let expandedWidth = expandedSearchControlWidth() 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 + || searchIconButton.isHidden searchFieldPresentationIsExpanded = isActive searchField.placeholderAttributedString = NSAttributedString( string: isActive ? "Search clips" : "", @@ -2998,9 +2260,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { guard animated, window != nil, widthChanged || visibilityChanged else { searchFieldWidthConstraint?.constant = targetWidth searchField.alphaValue = isActive ? 1 : 0 - searchIconButton.alphaValue = isActive ? 0 : 1 + searchIconButton.alphaValue = 1 searchField.isHidden = !isActive - searchIconButton.isHidden = isActive + searchIconButton.isHidden = false needsLayout = true shelfChromeView?.needsLayout = true headerStack?.needsLayout = true @@ -3011,17 +2273,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.isHidden = false searchIconButton.isHidden = false searchField.alphaValue = wasActive ? 1 : 0 - searchIconButton.alphaValue = wasActive ? 0 : 1 + searchIconButton.alphaValue = 1 shelfChromeView?.layoutSubtreeIfNeeded() headerStack?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() NSAnimationContext.runAnimationGroup { context in - context.duration = Motion.searchFieldResizeDuration + context.duration = Motion.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() @@ -3029,13 +2290,27 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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.searchIconButton.alphaValue = 1 self.searchField.isHidden = !isActive - self.searchIconButton.isHidden = isActive + self.searchIconButton.isHidden = false self.sizeCollectionDocument(consumingScrollReset: false) } } + private func expandedSearchControlWidth() -> CGFloat { + guard let shelfChromeView, let utilityToolbarGroup else { + return Metrics.maximumExpandedSearchWidth + } + shelfChromeView.layoutSubtreeIfNeeded() + let availableWidth = utilityToolbarGroup.frame.minX + - searchControlContainer.frame.minX + - Metrics.searchActionGap + guard availableWidth > Metrics.searchControlSize else { + return Metrics.maximumExpandedSearchWidth + } + return min(Metrics.maximumExpandedSearchWidth, availableWidth) + } + private func collapseSearchFieldIfIdle() { guard searchField.stringValue.isEmpty else { updateSearchFieldPresentation() @@ -3055,10 +2330,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { _ = window?.makeFirstResponder(searchField) } - @discardableResult - func focusSearchOrShowFilters() -> Bool { + func focusSearch() { focusSearchField() - return false } @discardableResult @@ -3086,12 +2359,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { var visibleCardPageStep: Int { 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 + let span = layout.height + layout.activeLift + cardDensity.cardSpacing + let visibleExtent = scrollView.contentView.bounds.height guard span > 0 else { return 1 } return max(1, Int(floor(visibleExtent / span))) } @@ -3146,6 +2415,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardItemCount } + var debugItemReloadCount: Int { + debugItemReloadCountValue + } + var debugLastSynchronousCardRenderCount: Int { lastSynchronousCardRenderCount } @@ -3182,6 +2455,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { Self.debugAlpha(layer?.borderColor) } + var debugPanelShadowOpacity: Float { + layer?.shadowOpacity ?? 0 + } + var debugCardAccessibilityLabels: [String] { cardViews.compactMap { $0.accessibilityLabel() } } @@ -3253,7 +2530,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var debugPanelLayout: String { - currentPanelLayout.title + "Side Shelf" } var debugResizeHandleIsHidden: Bool { @@ -3265,7 +2542,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private var debugToolbarButtons: [NSButton] { - [categoryMenuButton, clearHistoryButton, settingsButton].compactMap { $0 } + [clearHistoryButton, settingsButton].compactMap { $0 } } var debugToolbarButtonAccessibilityLabels: [String] { @@ -3296,22 +2573,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { searchField.accessibilityHelp() ?? "" } - var debugSearchFilterButtonAccessibilityHelp: String { - "" - } - var debugAddCollectionButtonAccessibilityHelp: String { addCollectionButton.accessibilityHelp() ?? "" } - var debugCompactModeButtonAccessibilityValue: String { - "" - } - - func debugToggleCompactMode() { - toggleCompactMode() - } - var debugCardSizes: [NSSize] { cardViews.map { $0.frame.size } } @@ -3400,6 +2665,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugHeaderColorHex ?? "" } + var debugFirstCardHeaderSurfaceColorHex: String { + cardViews.first?.debugHeaderSurfaceColorHex ?? "" + } + + var debugFirstCardHeaderSurfaceAlpha: CGFloat { + cardViews.first?.debugHeaderSurfaceAlpha ?? 0 + } + var debugFirstCardFooterDetailText: String { cardViews.first?.debugFooterDetailText ?? "" } @@ -3531,14 +2804,17 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var debugSelectedCardVisualTopInset: CGFloat { - let frame = debugSelectedCardFrameInPanel - return currentPanelLayout == .vertical ? frame.minY : bounds.maxY - frame.maxY + debugSelectedCardFrameInPanel.minY } var debugCardRailVisibleRect: NSRect { scrollView.contentView.bounds } + var debugCardRailFrameInPanel: NSRect { + scrollView.convert(scrollView.bounds, to: self) + } + var debugCardRailTopGap: CGFloat { bounds.maxY - scrollView.frame.maxY } @@ -3564,11 +2840,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } func debugScrollCardRailVertically(deltaY: CGFloat) { - if currentPanelLayout == .vertical { - scrollView.scrollVerticallyByVerticalDelta(deltaY) - } else { - scrollView.scrollHorizontallyByVerticalDelta(deltaY) - } + scrollView.scrollVerticallyByVerticalDelta(deltaY) } var debugFirstCardMenuTitles: [String] { @@ -3667,10 +2939,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugHeaderBadgeBorderWidth ?? 0 } - var debugStackCornerLabels: [String] { - cardViews.map(\.debugStackCornerLabel) - } - var debugCardObjectIdentifiers: [ObjectIdentifier] { cardViews.map(ObjectIdentifier.init) } @@ -3692,18 +2960,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { animatedCardLayoutChangeCount } - var debugStackCornerHiddenStates: [Bool] { - cardViews.map(\.debugStackCornerIsHidden) - } - - var debugFirstCardStackCornerFrame: NSRect { - cardViews.first?.debugStackCornerFrame ?? .zero - } - - func debugPressFirstCardStackCornerButton() { - cardViews.first?.debugPressStackCornerButton() - } - func debugPerformFirstCardMenuItem(titled title: String) { cardViews.first?.debugPerformMenuItem(titled: title) } @@ -3712,22 +2968,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugPerformCaptureRuleMenuItem(titled: title) } - var debugResultCountText: String { - currentResultCountText - } - - var debugStatusText: String { - currentStatusText - } - - var debugStatusBarIsVisible: Bool { - false - } - - var debugStatusTone: String { - currentStatusTone.rawValue - } - var debugCollectionTitles: [String] { configuredSortModes.compactMap { collectionButtons[$0]?.titleText } } @@ -3869,6 +3109,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { updateSearchText() } + func debugExpandSearchPresentationForSnapshot() { + searchFieldPresentationRequested = true + searchFieldCollapsedWhileIdle = false + updateSearchFieldPresentation(animated: false) + } + private func debugPressFocusedResponder( characters: String, keyCode: UInt16, @@ -3963,18 +3209,15 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionButtons[mode]?.debugCommandMouseDown() } - var debugCategoryMenuTitles: [String] { - categoryMenu().items.map { $0.isSeparatorItem ? "-" : $0.title } + func debugMouseDownCollectionChip( + _ mode: ClipboardSortMode, + modifiers: NSEvent.ModifierFlags + ) { + collectionButtons[mode]?.debugMouseDown(modifiers: modifiers) } - 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) + func debugCollectionChipBackgroundAlpha(_ mode: ClipboardSortMode) -> CGFloat { + collectionButtons[mode]?.debugBackgroundAlpha ?? 0 } var debugClearHistoryMenuTitles: [String] { @@ -4067,10 +3310,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { collectionNameProviderForTesting = provider } - func debugSetTextClipProvider(_ provider: @escaping () -> String?) { - textClipProviderForTesting = provider - } - func debugSuppressClipEditDialogs(_ suppress: Bool = true) { suppressClipEditDialogsForTesting = suppress } @@ -4099,10 +3338,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { createCollectionFromToolbar() } - func debugCreateTextClipFromToolbar() { - createTextClipFromToolbar() - } - func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] { customCollectionButtons[collectionName]?.debugMenuTitles ?? [] } @@ -4147,6 +3382,78 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { min(searchIconButton.frame.height, searchControlContainer.bounds.height) } + var debugSearchIconButtonCornerRadius: CGFloat { + searchIconButton.layer?.cornerRadius ?? 0 + } + + var debugSearchGlassMaterial: NSVisualEffectView.Material { + searchControlContainer.material + } + + var debugSearchGlassBlendingMode: NSVisualEffectView.BlendingMode { + searchControlContainer.blendingMode + } + + var debugSearchGlassCornerRadius: CGFloat { + searchControlContainer.layer?.cornerRadius ?? 0 + } + + var debugSearchGlassBorderAlpha: CGFloat { + Self.debugAlpha(searchControlContainer.layer?.borderColor) + } + + var debugSearchGlassTintAlpha: CGFloat { + Self.debugAlpha(searchControlContainer.layer?.backgroundColor) + } + + var debugSearchFieldUsesTransparentChrome: Bool { + !searchField.isBezeled && !searchField.drawsBackground + } + + var debugSearchIconIsPinnedToLeadingEdge: Bool { + abs(searchIconButton.frame.minX - searchControlContainer.bounds.minX) <= 0.5 + && abs(searchIconButton.frame.midY - searchControlContainer.bounds.midY) <= 0.5 + } + + var debugSearchIconLeadingOffset: NSPoint { + NSPoint( + x: searchIconButton.frame.minX - searchControlContainer.bounds.minX, + y: searchIconButton.frame.midY - searchControlContainer.bounds.midY + ) + } + + var debugSearchFieldIsCenteredInGlass: Bool { + abs(searchField.frame.midY - searchControlContainer.bounds.midY) < 0.5 + } + + var debugSearchControlIsLeadingAlignedInToolbar: Bool { + guard let shelfChromeView else { return false } + return abs(searchControlContainer.frame.minX - shelfChromeView.bounds.minX) <= 0.5 + } + + var debugSearchControlToolbarLeadingOffset: CGFloat { + guard let shelfChromeView else { return .infinity } + return searchControlContainer.frame.minX - shelfChromeView.bounds.minX + } + + var debugSearchTextLeadingGap: CGFloat { + guard let cell = searchField.cell as? NSSearchFieldCell else { return -.infinity } + let textRect = cell.searchTextRect(forBounds: searchField.bounds) + let textMinX = searchField.frame.minX + textRect.minX + return textMinX - searchIconButton.frame.maxX + } + + var debugSearchEditorLeadingGap: CGFloat { + guard let editor = searchField.currentEditor() else { return -.infinity } + let editorFrame = searchControlContainer.convert(editor.bounds, from: editor) + return editorFrame.minX - searchIconButton.frame.maxX + } + + var debugSearchControlTrailingActionGap: CGFloat { + guard let utilityToolbarGroup else { return -.infinity } + return utilityToolbarGroup.frame.minX - searchControlContainer.frame.maxX + } + var debugSearchAnimationDuration: TimeInterval { Motion.searchFieldResizeDuration } @@ -4175,45 +3482,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { !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 } @@ -4305,13 +3573,6 @@ 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 @@ -4378,7 +3639,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { clearCardHoverStates() } - private func claimMouseHoverSelection(at candidateIndex: Int, mouseLocation: NSPoint?) -> Int? { + private func claimMouseHover(at candidateIndex: Int, mouseLocation: NSPoint?) -> Int? { if hoverSelectionRequiresFreshMouseMovement { guard let location = mouseLocation else { return nil } if let barrier = hoverSelectionKeyboardBarrierLocation, @@ -4393,19 +3654,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { guard let index = visualHoverIndex(candidateIndex: candidateIndex, mouseLocation: mouseLocation) else { return nil } - cardSelectionInputSource = .mouse - selectionScrollSuppressionCount = 3 + let previousHoveredIndex = hoveredCardIndex hoveredCardIndex = index clearCardHoverStates(except: index) if index != candidateIndex { cardView(at: index)?.setHoverState(true, notifySelection: false, mouseLocation: mouseLocation) } + if previousHoveredIndex != index { + refreshRenderedCardPresentationForHoverChange() + } return index } private func visualHoverIndex(candidateIndex: Int, mouseLocation: NSPoint?) -> Int? { - guard currentPanelLayout == .vertical, - let mouseLocation else { + guard let mouseLocation else { return candidateIndex } return visualCardIndex(atWindowLocation: mouseLocation) @@ -4452,7 +3714,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } private func refreshRenderedCardPresentationForHoverChange() { - guard currentPanelLayout == .vertical, cardItemCount > 0 else { return } + guard cardItemCount > 0 else { return } let shouldAnimate = window != nil for card in cardViews { applyCurrentCardState(card, index: card.representedIndex, animated: shouldAnimate) @@ -4480,27 +3742,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } @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) + clearHistoryMenu().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 - } + @objc private func performClearHistoryMenuItem(_ sender: NSMenuItem) { 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) } @@ -4540,17 +3791,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) @@ -4570,10 +3810,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) + viewModel.exportCollection(named: collectionName, to: url) { [weak self] result in + if case .failure(let error) = result { + self?.viewModel.reportCollectionExportFailure(error) + } } } @@ -4595,33 +3835,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) @@ -5070,6 +4283,7 @@ private final class CollectionChipView: NSView { private var isStackCaptureActive = false private var isKeyboardFocused = false private var isDropTargeted = false + private var isHovered = false private var trackingAreaRef: NSTrackingArea? var onPress: (Bool) -> Void = { _ in } var onHover: (Bool) -> Void = { _ in } @@ -5217,8 +4431,11 @@ private final class CollectionChipView: NSView { layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor layer?.borderColor = color.withAlphaComponent(isKeyboardFocused ? 0.46 : 0.28).cgColor } else if isKeyboardFocused { - layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.06).cgColor layer?.borderColor = color.withAlphaComponent(0.34).cgColor + } else if isHovered { + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.06).cgColor + layer?.borderColor = color.withAlphaComponent(0.18).cgColor } else { layer?.backgroundColor = NSColor.clear.cgColor layer?.borderColor = NSColor.clear.cgColor @@ -5239,7 +4456,7 @@ private final class CollectionChipView: NSView { } private func updateCountLabelVisibility() { - countLabel.isHidden = iconOnly || count == 0 + countLabel.isHidden = iconOnly || count == 0 || !isSelected } private func updateAccessibility() { @@ -5249,12 +4466,20 @@ private final class CollectionChipView: NSView { setAccessibilityLabel("\(titleText), \(selectedText)\(captureText)\(count) \(noun)") setAccessibilityValue("\(count)") setAccessibilityHelp(accessibilityHelpText()) + let combineAction = NSAccessibilityCustomAction( + name: "Combine \(titleText) with selected categories" + ) { [weak self] in + guard let self else { return false } + self.activateFromUserInteraction(extending: true) + return true + } + setAccessibilityCustomActions([combineAction]) 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." + "Press Return or Space to show \(titleText); Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories." ] if hasStackMenuActions { parts.append("Open the context menu for Stack capture and Stack paste actions.") @@ -5289,6 +4514,12 @@ private final class CollectionChipView: NSView { setKeyboardFocusForPresentation(false) } + private func setHoveredForPresentation(_ hovered: Bool) { + guard isHovered != hovered else { return } + isHovered = hovered + updateChrome() + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } @@ -5309,33 +4540,35 @@ private final class CollectionChipView: NSView { } override func mouseEntered(with event: NSEvent) { + setHoveredForPresentation(true) onHover(true) } override func mouseExited(with event: NSEvent) { + setHoveredForPresentation(false) onHover(false) } override func mouseDown(with event: NSEvent) { - activateFromUserInteraction(extending: event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command)) + activateFromUserInteraction(extending: Self.shouldExtendSelection(for: event.modifierFlags)) } override func keyDown(with event: NSEvent) { switch event.keyCode { case 36, 49: - onPress(false) - case 125: - onMoveFocus(1) - case 126: + onPress(Self.shouldExtendSelection(for: event.modifierFlags)) + case 123: onMoveFocus(-1) + case 124: + onMoveFocus(1) + case 125: + onMoveCardSelection(1) + case 126: + onMoveCardSelection(-1) case 115: onSelectFirst() case 119: onSelectLast() - case 123: - onMoveCardSelection(-1) - case 124: - onMoveCardSelection(1) default: if let text = shelfSearchText(from: event) { onStartSearch(text) @@ -5355,6 +4588,11 @@ private final class CollectionChipView: NSView { onPress(extending) } + private static func shouldExtendSelection(for modifierFlags: NSEvent.ModifierFlags) -> Bool { + let selectionModifiers = modifierFlags.intersection([.command, .option, .control, .shift]) + return selectionModifiers == .command + } + override func menu(for event: NSEvent) -> NSMenu? { guard hasContextMenuActions else { return nil } return contextMenu() @@ -5561,6 +4799,14 @@ private final class CollectionChipView: NSView { frame.width } + var debugBackgroundAlpha: CGFloat { + guard let backgroundColor = layer?.backgroundColor, + let color = NSColor(cgColor: backgroundColor) else { + return 0 + } + return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent + } + func debugDropItem(_ itemID: UUID) { onDropItem?(itemID) } @@ -5573,7 +4819,12 @@ private final class CollectionChipView: NSView { activateFromUserInteraction(extending: true) } + func debugMouseDown(modifiers: NSEvent.ModifierFlags) { + activateFromUserInteraction(extending: Self.shouldExtendSelection(for: modifiers)) + } + func debugSetHovered(_ hovered: Bool) { + setHoveredForPresentation(hovered) onHover(hovered) } @@ -5664,13 +4915,13 @@ private final class HeaderBadgeTileView: NSView { 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.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.52).cgColor + layer.borderWidth = 0.5 + layer.borderColor = NSColor.separatorColor.withAlphaComponent(0.20).cgColor layer.shadowColor = NSColor.black.cgColor - layer.shadowOpacity = 0.14 - layer.shadowRadius = 8 - layer.shadowOffset = NSSize(width: 0, height: 2) + layer.shadowOpacity = 0 + layer.shadowRadius = 0 + layer.shadowOffset = .zero } } @@ -5735,7 +4986,7 @@ private final class ClipboardItemCardSlotView: NSView { superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() NSAnimationContext.runAnimationGroup { context in - context.duration = ClipboardPanelView.Motion.cardLiftDuration + context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardLiftDuration) context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) topConstraint?.constant = targetOffset superview?.animator().layoutSubtreeIfNeeded() @@ -5775,8 +5026,6 @@ private final class ClipboardItemCardSlotView: NSView { private final class ClipboardItemCardView: NSView, NSDraggingSource { private enum Metrics { static let dragThreshold: CGFloat = 4 - static let actionRailBadgeGap: CGFloat = 8 - static let actionRailLeadingMargin: CGFloat = 10 } private enum Palette { static let border = NSColor.white.withAlphaComponent(0.42).cgColor @@ -5786,9 +5035,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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] @@ -5799,28 +5045,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { ] } - private struct ActionRailButtonSpec { - let systemName: String - let toolTip: String - let action: Selector - let isPrimary: Bool - let overflowPriority: Int? - - init( - _ systemName: String, - toolTip: String, - action: Selector, - isPrimary: Bool = false, - overflowPriority: Int? = nil - ) { - self.systemName = systemName - self.toolTip = toolTip - self.action = action - self.isPrimary = isPrimary - self.overflowPriority = overflowPriority - } - } - var onSelect: (Int, ClipboardSelectionMode) -> Void = { _, _ in } var onHover: (Int, NSPoint?) -> Bool = { _, _ in true } var onHoverExit: (Int) -> Void = { _ in } @@ -5883,12 +5107,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private let contentView = NSView() private let footerSourceLabel = NSTextField(labelWithString: "") private let footerDetailLabel = NSTextField(labelWithString: "") - 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? @@ -5900,7 +5118,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private var isKeyboardFocused = false private var mouseDownLocation: NSPoint? private var trackingAreaRef: NSTrackingArea? - private var presentation: ClipboardCardPresentation = .expanded + private var presentation: ClipboardCardPresentation = .verticalFocus private var presentationAnimationGeneration = 0 private var widthConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? @@ -5928,7 +5146,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { self.index = index isHovered = false mouseDownLocation = nil - updateActionRailVisibility() + updateSupplementaryChrome() } init( @@ -5990,15 +5208,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } onLiftStateChanged(emphasized) setAccessibilityValue(selected ? "Selected" : "Not selected") - updateActionRailVisibility() + updateSupplementaryChrome() } func setStackState(isStacked: Bool, stackCount: Int) { guard itemIsStacked != isStacked || self.stackCount != stackCount else { return } itemIsStacked = isStacked self.stackCount = stackCount - configureStackCornerButton() - updateActionRailVisibility() + updateSupplementaryChrome() } func setPresentation( @@ -6025,10 +5242,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { compactContentView?.isHidden = false } - compactTextStack?.isHidden = presentation == .horizontalIcon + compactTextStack?.isHidden = false compactMetricLabel?.isHidden = presentation != .verticalRow - let targetCornerRadius = cardCornerRadius(for: presentation, size: size) - stackCornerButton.isHidden = !presentation.showsFloatingActions || !(itemIsStacked || isKeyboardFocused) + let targetCornerRadius = cardCornerRadius(for: presentation) let applyFinalVisibility = { [weak self] in guard let self else { return } @@ -6056,7 +5272,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { expandedDetailHeightConstraint?.constant = detailHeight applyFinalVisibility() if changed { - updateActionRailVisibility() + updateSupplementaryChrome() needsLayout = true } return @@ -6083,7 +5299,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { debugAnimatedPresentationChangeCount += 1 #endif NSAnimationContext.runAnimationGroup { context in - context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming context.allowsImplicitAnimation = true heightConstraint?.animator().constant = size.height @@ -6095,7 +5311,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } if changed { - updateActionRailVisibility() + updateSupplementaryChrome() needsLayout = true } return @@ -6110,7 +5326,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { debugAnimatedPresentationChangeCount += 1 #endif NSAnimationContext.runAnimationGroup { context in - context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming context.allowsImplicitAnimation = true heightConstraint?.animator().constant = size.height @@ -6122,7 +5338,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } if changed { - updateActionRailVisibility() + updateSupplementaryChrome() needsLayout = true } return @@ -6134,7 +5350,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { debugAnimatedPresentationChangeCount += 1 #endif NSAnimationContext.runAnimationGroup { context in - context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming context.allowsImplicitAnimation = true heightConstraint?.animator().constant = size.height @@ -6146,7 +5362,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } if changed { - updateActionRailVisibility() + updateSupplementaryChrome() needsLayout = true } return @@ -6158,7 +5374,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { debugAnimatedPresentationChangeCount += 1 #endif NSAnimationContext.runAnimationGroup { context in - context.duration = ClipboardPanelView.Motion.cardExpansionDuration + context.duration = ClipboardPanelView.Motion.duration(ClipboardPanelView.Motion.cardExpansionDuration) context.timingFunction = ClipboardPanelView.Motion.cardExpansionTiming widthConstraint?.animator().constant = size.width heightConstraint?.animator().constant = size.height @@ -6173,7 +5389,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } if changed { - updateActionRailVisibility() + updateSupplementaryChrome() needsLayout = true } } @@ -6183,7 +5399,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { if window == nil { CATransaction.setDisableActions(true) } else { - CATransaction.setAnimationDuration(0.16) + CATransaction.setAnimationDuration(ClipboardPanelView.Motion.duration(0.16)) CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) } changes() @@ -6192,9 +5408,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private func applyCardShadow(emphasized: Bool) { if presentation == .verticalRow || presentation == .verticalFocus { - layer?.shadowOpacity = 0 - layer?.shadowRadius = 0 - layer?.shadowOffset = .zero + layer?.shadowOpacity = emphasized ? 0.12 : 0.035 + layer?.shadowRadius = emphasized ? 12 : 4 + layer?.shadowOffset = NSSize(width: 0, height: emphasized ? -4 : -1) return } layer?.shadowOpacity = emphasized ? 0.24 : 0.09 @@ -6203,13 +5419,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } 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 + return selected ? Palette.selectedSurface : Palette.cardSurface } override var acceptsFirstResponder: Bool { @@ -6275,9 +5488,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onExtendSelectionToLast() case 121: onPageExtendSelection(1) - case 123: + case 126: onExtendSelection(-1) - case 124: + case 125: onExtendSelection(1) default: super.keyDown(with: event) @@ -6304,9 +5517,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onSelectLast() case 121: onPageSelection(1) - case 123: + case 126: onMoveSelection(-1) - case 124: + case 125: onMoveSelection(1) default: if let text = shelfSearchText(from: event) { @@ -6494,19 +5707,19 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } var debugVisibleActionLabels: [String] { - actionRailIsPresented ? actionRailButtons.map { $0.toolTip ?? "" } : [] + [] } var debugVisibleActionRailWidth: CGFloat { - actionRailIsPresented ? actionRailContentWidth : 0 + 0 } var debugActionRailAlpha: CGFloat { - actionRail.alphaValue + 0 } var debugConstructedActionButtonCount: Int { - actionRailButtons.count + 0 } var debugShadowOpacity: Float { @@ -6569,6 +5782,22 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { compactContentView?.layer?.maskedCorners ?? [] } + var debugHeaderSurfaceColorHex: String { + guard let backgroundColor = compactContentView?.layer?.backgroundColor, + let color = NSColor(cgColor: backgroundColor) else { + return "" + } + return Self.debugHex(color) + } + + var debugHeaderSurfaceAlpha: CGFloat { + guard let backgroundColor = compactContentView?.layer?.backgroundColor, + let color = NSColor(cgColor: backgroundColor) else { + return 0 + } + return (color.usingColorSpace(.deviceRGB) ?? color).alphaComponent + } + var debugExpandedDetailFrame: NSRect { guard let fullContentView else { return .zero } return fullContentView.convert(fullContentView.bounds, to: self) @@ -6607,22 +5836,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { headerBadgeView?.layer?.borderWidth ?? 0 } - var debugStackCornerLabel: String { - stackCornerButton.toolTip ?? "" - } - - var debugStackCornerIsHidden: Bool { - stackCornerButton.isHidden - } - - var debugStackCornerFrame: NSRect { - stackCornerButton.convert(stackCornerButton.bounds, to: self) - } - - func debugPressStackCornerButton() { - 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) @@ -6658,12 +5871,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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: @@ -6908,136 +6115,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } } - private func configureActionRail() { - actionRail.orientation = .horizontal - actionRail.alignment = .centerY - actionRail.spacing = 4 - actionRail.edgeInsets = NSEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) - actionRail.wantsLayer = true - actionRail.layer?.cornerRadius = layout.actionRailHeight / 2 - actionRail.layer?.backgroundColor = Palette.actionRailBackground - actionRail.layer?.borderWidth = 0.5 - actionRail.layer?.borderColor = Palette.actionRailBorder - actionRail.layer?.shadowColor = NSColor.black.cgColor - 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()) - let buttons = specs.map { spec in - cardActionButton( - spec.systemName, - toolTip: spec.toolTip, - action: spec.action, - isPrimary: spec.isPrimary - ) - } - - for button in buttons { - actionRail.addArrangedSubview(button) - } - actionRailButtons = buttons - let contentWidth = actionRailWidth(for: specs) - actionRailContentWidth = contentWidth - if actionRailIsPresented { - actionRailWidthConstraint?.constant = contentWidth - } - } - - private func preferredActionRailButtonSpecs() -> [ActionRailButtonSpec] { - let pinTitle = itemIsPinned ? "Unpin" : "Pin" - var specs: [ActionRailButtonSpec] = [ - ActionRailButtonSpec("return", toolTip: "Paste", action: #selector(pasteFromMenu), isPrimary: true), - ActionRailButtonSpec("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)) - ] - - if canPlainText { - specs.append(ActionRailButtonSpec("textformat", toolTip: "Paste Plain Text", action: #selector(pastePlainTextFromMenu))) - specs.append(ActionRailButtonSpec("doc.plaintext", toolTip: "Copy Plain Text", action: #selector(copyPlainTextFromMenu), overflowPriority: 20)) - } - - specs.append(ActionRailButtonSpec(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu), overflowPriority: 30)) - specs.append(ActionRailButtonSpec("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:)))) - - 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)) - } - if canOpen { - specs.append(ActionRailButtonSpec("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu), overflowPriority: 50)) - } - if canReveal { - specs.append(ActionRailButtonSpec("magnifyingglass", toolTip: "Reveal", action: #selector(revealFromMenu), overflowPriority: 40)) - } - specs.append(ActionRailButtonSpec("trash", toolTip: "Delete", action: #selector(deleteFromMenu), overflowPriority: 10)) - return specs - } - - private func fittedActionRailButtonSpecs(from specs: [ActionRailButtonSpec]) -> [ActionRailButtonSpec] { - let maximumWidth = maximumVisibleActionRailWidth - guard actionRailWidth(for: specs) > maximumWidth else { return specs } - - let moreSpec = ActionRailButtonSpec("ellipsis.circle", toolTip: "More", action: #selector(showMoreActionsFromActionRail(_:))) - let overflowCandidates = specs.enumerated().compactMap { index, spec -> (index: Int, priority: Int)? in - guard let priority = spec.overflowPriority else { return nil } - return (index, priority) - }.sorted { lhs, rhs in - lhs.priority == rhs.priority ? lhs.index > rhs.index : lhs.priority < rhs.priority - } - - var hiddenIndexes = Set() - for candidate in overflowCandidates { - hiddenIndexes.insert(candidate.index) - let visibleSpecs = specs.enumerated().compactMap { index, spec in - hiddenIndexes.contains(index) ? nil : spec - } + [moreSpec] - if actionRailWidth(for: visibleSpecs) <= maximumWidth { - return visibleSpecs - } - } - - return specs.enumerated().compactMap { index, spec in - hiddenIndexes.contains(index) ? nil : spec - } + [moreSpec] - } - - private func actionRailWidth(for specs: [ActionRailButtonSpec]) -> CGFloat { - guard !specs.isEmpty else { return 0 } - let buttonWidth = specs.reduce(CGFloat(0)) { width, spec in - width + (spec.isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize) - } - return buttonWidth - + CGFloat(max(0, specs.count - 1)) * actionRail.spacing - + actionRail.edgeInsets.left - + actionRail.edgeInsets.right - } - - private var maximumVisibleActionRailWidth: CGFloat { - layout.width - (Metrics.actionRailLeadingMargin * 2) - } - private var summaryHeaderHeight: CGFloat { ClipboardCardPresentation.verticalRow.size(for: layout).height } @@ -7051,15 +6128,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private var headerBadgeSize: CGFloat { - summaryHeaderHeight + layout.isCompact ? 40 : 44 } private var verticalHeaderCornerRadius: CGFloat { - min(layout.width, summaryHeaderHeight) / 3 + layout.isCompact ? 12 : 14 } private var headerBadgeCornerRadius: CGFloat { - layout.isCompact ? 13 : 15 + layout.isCompact ? 10 : 11 } private var headerBadgeIconInset: CGFloat { @@ -7070,130 +6147,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { layout.isCompact ? 6 : 7 } - private var stackCornerButtonSize: CGFloat { - layout.isCompact ? 28 : 30 - } - - private var actionRailHeaderTopInset: CGFloat { - max(8, (layout.headerHeight - layout.actionRailHeight) / 2) - } - - private func cardCornerRadius(for presentation: ClipboardCardPresentation, size: NSSize) -> CGFloat { + private func cardCornerRadius(for presentation: ClipboardCardPresentation) -> 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, - action: Selector, - isPrimary: Bool = false - ) -> NSButton { - let button = NSButton(title: "", target: self, action: action) - let image = NSImage(systemSymbolName: systemName, accessibilityDescription: toolTip) - image?.isTemplate = true - button.image = image - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyDown - button.isBordered = false - button.wantsLayer = true - let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize - button.layer?.cornerRadius = size / 2 - if isPrimary { - button.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.92).cgColor - button.contentTintColor = .white - } else if toolTip == "Collect" { - 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 = Palette.secondaryActionBackground - button.contentTintColor = NSColor.labelColor.withAlphaComponent(0.74) - } - button.toolTip = toolTip - button.setAccessibilityLabel(toolTip) - button.translatesAutoresizingMaskIntoConstraints = false - button.widthAnchor.constraint(equalToConstant: size).isActive = true - button.heightAnchor.constraint(equalToConstant: size).isActive = true - return button - } - - private func configureStackCornerButton() { - let toolTip = itemIsStacked ? "Remove from Stack" : "Add to Stack" - let image = NSImage(systemSymbolName: itemIsStacked ? "checkmark" : "plus", accessibilityDescription: toolTip) - image?.isTemplate = true - stackCornerButton.image = image - stackCornerButton.imagePosition = .imageOnly - stackCornerButton.imageScaling = .scaleProportionallyDown - stackCornerButton.isBordered = false - stackCornerButton.wantsLayer = true - stackCornerButton.layer?.cornerRadius = stackCornerButtonSize / 2 - stackCornerButton.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.94).cgColor - stackCornerButton.layer?.borderWidth = 1 - stackCornerButton.layer?.borderColor = NSColor.white.withAlphaComponent(0.82).cgColor - stackCornerButton.layer?.shadowColor = NSColor.black.cgColor - stackCornerButton.layer?.shadowOpacity = 0.22 - stackCornerButton.layer?.shadowRadius = 7 - stackCornerButton.layer?.shadowOffset = NSSize(width: 0, height: 3) - stackCornerButton.contentTintColor = .white - stackCornerButton.toolTip = toolTip - stackCornerButton.setAccessibilityLabel(toolTip) - stackCornerButton.target = self - stackCornerButton.action = #selector(toggleStackFromCornerButton) - stackCornerButton.translatesAutoresizingMaskIntoConstraints = false - stackCornerButton.setContentHuggingPriority(.required, for: .horizontal) - stackCornerButton.setContentCompressionResistancePriority(.required, for: .horizontal) - } - - private func updateActionRailVisibility() { - let shouldShowActionRail = presentation.showsFloatingActions && (isHovered || (isActiveSelection && isKeyboardFocused)) - if shouldShowActionRail { - ensureActionRailButtons() - } - let targetWidth = shouldShowActionRail ? actionRailContentWidth : 0 - let visibilityChanged = actionRailIsPresented != shouldShowActionRail - actionRailIsPresented = shouldShowActionRail + private func updateSupplementaryChrome() { headerBadgeView?.isHidden = false headerPinView?.isHidden = isActiveSelection footerDetailLabel.isHidden = false - 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() { @@ -7224,14 +6188,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onAddSelectionToStack(index) } - @objc private func showMoreActionsFromActionRail(_ sender: NSButton) { - contextMenu().popUp( - positioning: nil, - at: NSPoint(x: sender.bounds.minX, y: sender.bounds.minY - 4), - in: sender - ) - } - @objc private func toggleStackFromMenu() { onToggleStack(index) } @@ -7240,11 +6196,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onAddVisibleToStack() } - @objc private func toggleStackFromCornerButton() { - onSelect(index, .activate) - onToggleStack(index) - } - @objc private func pasteStackNextFromMenu() { onPasteStackNext() } @@ -7301,11 +6252,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onTogglePin(index) } - @objc private func showCollectionMenuFromAction(_ sender: NSButton) { - let menu = collectionAssignmentMenu() - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.maxY + 4), in: sender) - } - @objc private func assignToCollectionFromMenu(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String else { return } onAssignCollection(index, name) @@ -7433,25 +6379,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { NSLayoutConstraint.activate(fullContentConstraints) NSLayoutConstraint.activate(compactContentConstraints) - configureStackCornerButton() - contentView.addSubview(stackCornerButton) - addSubview(actionRail) - NSLayoutConstraint.activate([ - stackCornerButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), - stackCornerButton.centerYAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -layout.footerHeight), - stackCornerButton.widthAnchor.constraint(equalToConstant: stackCornerButtonSize), - stackCornerButton.heightAnchor.constraint(equalToConstant: stackCornerButtonSize), - actionRail.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - actionRail.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 5) - ]) - setPresentation(.expanded) + setPresentation(.verticalFocus) setSelected(false) } private func compactSummaryView(for item: ClipboardItem) -> NSView { let header = NSView() + let accent = headerColor(for: item) header.wantsLayer = true - header.layer?.backgroundColor = headerColor(for: item).withAlphaComponent(0.96).cgColor + header.layer?.backgroundColor = accent.withAlphaComponent(0.96).cgColor header.layer?.cornerRadius = verticalHeaderCornerRadius header.layer?.maskedCorners = CornerMasks.topOnly header.layer?.masksToBounds = true @@ -7478,15 +6414,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { titleAndSubtitle.translatesAutoresizingMaskIntoConstraints = false compactTextStack = titleAndSubtitle - let metricLabel = compactMetricLabel(for: item) - compactMetricLabel = metricLabel + compactMetricLabel = nil var labelViews: [NSView] = [] if let quickPasteBadge = quickPasteBadge() { labelViews.append(quickPasteBadge) } labelViews.append(titleAndSubtitle) - labelViews.append(metricLabel) let labelStack = NSStackView(views: labelViews) labelStack.orientation = .horizontal labelStack.alignment = .centerY @@ -7497,10 +6431,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { let badge = iconBadge(for: item) headerBadgeView = badge + let accentBar = NSView() + accentBar.wantsLayer = true + accentBar.layer?.cornerRadius = 1.5 + accentBar.layer?.backgroundColor = accent.cgColor + accentBar.translatesAutoresizingMaskIntoConstraints = false let separator = NSView() separator.wantsLayer = true separator.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.18).cgColor separator.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(accentBar) header.addSubview(labelStack) header.addSubview(badge) header.addSubview(separator) @@ -7510,15 +6450,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { 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), + accentBar.leadingAnchor.constraint(equalTo: header.leadingAnchor), + accentBar.topAnchor.constraint(equalTo: header.topAnchor, constant: 8), + accentBar.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -8), + accentBar.widthAnchor.constraint(equalToConstant: 3), + labelStack.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset + 3), labelStack.centerYAnchor.constraint(equalTo: header.centerYAnchor), - labelStack.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -12), - badge.trailingAnchor.constraint(equalTo: header.trailingAnchor), - badge.topAnchor.constraint(equalTo: header.topAnchor), + labelStack.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -10), + badge.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -8), + badge.centerYAnchor.constraint(equalTo: header.centerYAnchor), badge.widthAnchor.constraint(equalToConstant: headerBadgeSize), badge.heightAnchor.constraint(equalToConstant: headerBadgeSize), separator.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset), @@ -7547,7 +6488,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private func compactMetricLabel(for item: ClipboardItem) -> NSTextField { let label = NSTextField(labelWithString: compactMetricText(for: item)) label.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 10 : 11, weight: .semibold) - label.textColor = NSColor.white.withAlphaComponent(0.82) + label.textColor = .tertiaryLabelColor label.alignment = .right label.lineBreakMode = .byClipping label.maximumNumberOfLines = 1 @@ -7557,13 +6498,16 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { } private func headerTitle(for item: ClipboardItem) -> String { - activeCollectionName ?? kindLabel(for: item.kind) + titleText(for: item) } private func headerSubtitle(for item: ClipboardItem) -> String { let relativeDate = Self.relativeDateText(for: item.createdAt) - guard activeCollectionName != nil else { return relativeDate } - return "\(kindLabel(for: item.kind)) - \(relativeDate)" + let context = [kindLabel(for: item.kind), itemSourceAppName] + .compactMap { $0?.clipboardTrimmed } + .filter { !$0.isEmpty } + .joined(separator: " • ") + return context.isEmpty ? relativeDate : "\(context) • \(relativeDate)" } private func headerColor(for item: ClipboardItem) -> NSColor { @@ -7609,9 +6553,6 @@ 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) } @@ -7649,73 +6590,6 @@ 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 { @@ -8906,8 +7780,6 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { footerDetailLabel.maximumNumberOfLines = 1 footerDetailLabel.toolTip = footerDetailLabel.stringValue - configureActionRail() - let divider = NSView() divider.wantsLayer = true divider.layer?.backgroundColor = Palette.divider diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 2ebd1d6..12e8ce6 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -23,18 +23,6 @@ struct ClipboardCollectionCountSummary { } } -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() @@ -46,6 +34,7 @@ final class ClipboardPanelViewModel { return formatter }() private static let searchLocale = Locale(identifier: "en_US_POSIX") + private static let fallbackSourceDeviceName = ClipboardItem.localDeviceName private struct StructuredFilterMatcher { let tokens: [String] @@ -88,8 +77,8 @@ final class ClipboardPanelViewModel { } private struct CategoryFilterSelection: Hashable { - let sortModeRawValues: [Int] - let collectionNameKeys: [String] + let sortModeRawValues: Set + let collectionNameKeys: Set static let empty = CategoryFilterSelection(sortModeRawValues: [], collectionNameKeys: []) @@ -107,6 +96,39 @@ final class ClipboardPanelViewModel { let item: ClipboardItem } + private struct SearchMatchCacheEntry { + let query: String + let indexedItems: [IndexedClipboardItem] + } + + private struct SearchDocumentFingerprint: Equatable { + let kindRawValue: Int + let displayText: String + let payload: String + let customTitle: String? + let sourceApp: String? + let sourceAppBundleID: String? + let collectionName: String? + let sourceDeviceName: String? + let ocrText: String? + } + + private struct SearchDocument { + let fingerprint: SearchDocumentFingerprint + let text: String + let textIncludingOCR: String + let sourceValues: [String] + let sourceText: String + let device: String + let collection: String + } + + private struct ThumbnailRequestKey: Hashable { + let itemID: UUID + let kindRawValue: Int + let source: String? + } + private enum VisibleItemsSortOrdering { case recency case useCount @@ -151,9 +173,7 @@ final class ClipboardPanelViewModel { didSet { guard oldValue != sortMode else { return } guard !isBatchingFilterSelectionChanges else { - if !suppressDefaultSortModePersistence { - settings.defaultSortMode = sortMode - } + settings.defaultSortMode = sortMode return } selectedSortModeFilterRawValues.removeAll(keepingCapacity: true) @@ -162,9 +182,7 @@ final class ClipboardPanelViewModel { isStackFilterSelected = false selectedCollectionName = nil isBatchingFilterSelectionChanges = false - if !suppressDefaultSortModePersistence { - settings.defaultSortMode = sortMode - } + settings.defaultSortMode = sortMode recomputeVisibleItems() onSortModeChanged?(sortMode) } @@ -202,13 +220,16 @@ final class ClipboardPanelViewModel { } private(set) var selectedItemIDs: [UUID] = [] { didSet { - selectedItemIDSet = Set(selectedItemIDs) guard oldValue != selectedItemIDs else { return } + selectedItemIDSet = Set(selectedItemIDs) notifyMain { self.onSelectedItemsChanged?() } } } private(set) var statusMessage: String = "" { - didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } } + didSet { + guard oldValue != statusMessage else { return } + notifyMain { self.onStatusMessageChanged?(self.statusMessage) } + } } private var items: [ClipboardItem] = [] { @@ -217,6 +238,9 @@ final class ClipboardPanelViewModel { collectionNamesCache = nil visibleItemsCache.removeAll(keepingCapacity: true) collectionCountCache = nil + searchMatchCache = nil + stackItemsNeedPruning = true + searchDocumentsByItemID = searchDocumentsByItemID.filter { itemIDSet.contains($0.key) } } } private let store: ClipboardStore @@ -224,19 +248,48 @@ final class ClipboardPanelViewModel { private let cacheService: ClipboardCacheService private let pasteService: PasteActionService private let imageTextExtractor: (NSImage) -> String? + private let thumbnailLoader: (ClipboardItem) -> NSImage? + private let thumbnailLoadQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "clipboard.panel.thumbnail-loader" + queue.qualityOfService = .userInitiated + queue.maxConcurrentOperationCount = 2 + return queue + }() + private let mediaActionQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "clipboard.panel.media-actions" + queue.qualityOfService = .userInitiated + queue.maxConcurrentOperationCount = 1 + return queue + }() + private let thumbnailRequestLock = NSLock() + private var thumbnailCompletionsByRequest: [ThumbnailRequestKey: [(NSImage?) -> Void]] = [:] 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 selectedSortModeFilterRawValues: Set = [] { + didSet { + guard oldValue != selectedSortModeFilterRawValues else { return } + categoryFilterSelectionCache = nil + } + } + private var selectedCollectionNameFilters: [String] = [] { + didSet { + guard oldValue != selectedCollectionNameFilters else { return } + categoryFilterSelectionCache = nil + } + } 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 searchMatchCache: SearchMatchCacheEntry? + private var searchDocumentsByItemID: [UUID: SearchDocument] = [:] + private var categoryFilterSelectionCache: CategoryFilterSelection? private var itemByID: [UUID: ClipboardItem] = [:] private var itemIDSet: Set = [] private var indexedItemsByCollectionKey: [String: [IndexedClipboardItem]] = [:] @@ -246,10 +299,11 @@ final class ClipboardPanelViewModel { private var visibleIndexByID: [UUID: Int] = [:] private var selectedItemIDSet: Set = [] private var stackItemIDSet: Set = [] + private var stackItemsNeedPruning = false private var stackItemIDs: [UUID] = [] { didSet { - stackItemIDSet = Set(stackItemIDs) guard oldValue != stackItemIDs else { return } + stackItemIDSet = Set(stackItemIDs) notifyMain { self.onStackChanged?() } } } @@ -269,21 +323,26 @@ final class ClipboardPanelViewModel { var onCollectionsChanged: (() -> Void)? var onStackChanged: (() -> Void)? var onCaptureStatusChanged: (() -> Void)? - var onCompactModeChanged: (() -> Void)? - var onPanelLayoutChanged: (() -> Void)? #if DEBUG private(set) var debugVisibleItemsFullScanCount = 0 private(set) var debugVisibleItemsIndexedLookupCount = 0 private(set) var debugCollectionCountFullScanCount = 0 private(set) var debugCollectionCountIndexedLookupCount = 0 + private(set) var debugSearchItemEvaluationCount = 0 + private(set) var debugSearchMatchCacheHitCount = 0 + private(set) var debugSearchDocumentBuildCount = 0 + private(set) var debugSearchDocumentCacheHitCount = 0 + private(set) var debugCategoryFilterSelectionBuildCount = 0 + private(set) var debugStackPruneScanCount = 0 #endif init( store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService, - imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:) + imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:), + thumbnailLoader: ((ClipboardItem) -> NSImage?)? = nil ) { self.store = store self.settings = settings @@ -291,6 +350,7 @@ final class ClipboardPanelViewModel { self.sortMode = settings.defaultSortMode self.pasteService = PasteActionService(cacheService: cacheService) self.imageTextExtractor = imageTextExtractor + self.thumbnailLoader = thumbnailLoader ?? { cacheService.previewThumbnail(for: $0) } store.observeItems { [weak self] list in guard let self else { return } @@ -308,25 +368,23 @@ final class ClipboardPanelViewModel { switch change { case .captureStatus: self?.statusMessage = "" - 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() + guard let self else { return } + self.collectionCountCache = nil + self.visibleItemsCache.removeAll(keepingCapacity: true) + self.searchMatchCache = nil + let query = Self.normalizedSearchValue(self.searchText) + guard !query.isEmpty, !self.parseSearchQuery(query).textTokens.isEmpty else { return } + self.recomputeVisibleItems() default: break } @@ -340,6 +398,12 @@ final class ClipboardPanelViewModel { debugVisibleItemsIndexedLookupCount = 0 debugCollectionCountFullScanCount = 0 debugCollectionCountIndexedLookupCount = 0 + debugSearchItemEvaluationCount = 0 + debugSearchMatchCacheHitCount = 0 + debugSearchDocumentBuildCount = 0 + debugSearchDocumentCacheHitCount = 0 + debugCategoryFilterSelectionBuildCount = 0 + debugStackPruneScanCount = 0 } #endif @@ -349,7 +413,15 @@ final class ClipboardPanelViewModel { } var selectedItemCount: Int { - selectedItemsInSelectionOrder().count + let visibleSelectionCount = selectedItemIDs.reduce(into: 0) { count, id in + if visibleItemByID[id] != nil { + count += 1 + } + } + if visibleSelectionCount > 0 { + return visibleSelectionCount + } + return selectedItem == nil ? 0 : 1 } var canShowSelectedInClipboard: Bool { @@ -377,18 +449,6 @@ final class ClipboardPanelViewModel { "Stack" } - var isCompactModeEnabled: Bool { - false - } - - var panelLayout: ClipboardPanelLayout { - .vertical - } - - func toggleCompactMode() { - statusMessage = "Compact Mode was removed" - } - var collectionNames: [String] { if let collectionNamesCache { return collectionNamesCache @@ -418,7 +478,7 @@ final class ClipboardPanelViewModel { } var searchFilterDeviceNames: [String] { - uniqueSearchFacetValues(items.map { Optional($0.effectiveSourceDeviceName) }) + uniqueSearchFacetValues(items.map { Optional(effectiveSourceDeviceName(for: $0)) }) } func collectionCount(for sortMode: ClipboardSortMode) -> Int { @@ -430,16 +490,15 @@ final class ClipboardPanelViewModel { } func collectionCountSummary() -> ClipboardCollectionCountSummary { - let query = searchText.clipboardTrimmed.lowercased() + let query = Self.normalizedSearchValue(searchText) 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 { + if query.isEmpty { for mode in ClipboardSortMode.allCases { sortModeCounts[mode.rawValue] = indexedItemsBySortMode[mode.rawValue]?.count ?? 0 } @@ -450,14 +509,9 @@ final class ClipboardPanelViewModel { 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 - } + for indexedItem in indexedItemsMatchingSearch(query) { + let item = indexedItem.item + incrementSortModeCounts(for: item, counts: &sortModeCounts) if let collectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName) { collectionCounts[collectionName.lowercased(), default: 0] += 1 } @@ -503,8 +557,30 @@ final class ClipboardPanelViewModel { settings.captureStatusMessage } - func thumbnail(for item: ClipboardItem) -> NSImage? { - cacheService.previewThumbnail(for: item) + /// Loads preview media away from the main thread and coalesces matching in-flight requests. + /// The completion is always delivered asynchronously on the main queue. + func loadThumbnail(for item: ClipboardItem, completion: @escaping (NSImage?) -> Void) { + guard let requestKey = thumbnailRequestKey(for: item) else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + thumbnailRequestLock.lock() + if var completions = thumbnailCompletionsByRequest[requestKey] { + completions.append(completion) + thumbnailCompletionsByRequest[requestKey] = completions + thumbnailRequestLock.unlock() + return + } + thumbnailCompletionsByRequest[requestKey] = [completion] + thumbnailRequestLock.unlock() + + thumbnailLoadQueue.addOperation { [self] in + let thumbnail = self.thumbnailLoader(item) + self.finishThumbnailRequest(requestKey, thumbnail: thumbnail) + } } func selectItem(at index: Int, mode: ClipboardSelectionMode = .replace) { @@ -525,7 +601,7 @@ final class ClipboardPanelViewModel { setActiveSelection(item, at: index, selectedIDs: nextIDs, anchorID: item.id) case .range: let anchorIndex = selectionAnchorItemID - .flatMap { anchorID in visibleItems.firstIndex { $0.id == anchorID } } + .flatMap { visibleIndexByID[$0] } ?? selectedIndex let lower = min(anchorIndex, index) let upper = max(anchorIndex, index) @@ -533,9 +609,15 @@ final class ClipboardPanelViewModel { let anchorID = visibleItems[anchorIndex].id setActiveSelection(item, at: index, selectedIDs: rangeIDs, anchorID: anchorID) case .hover: + guard selectedItemID != item.id + || selectedIndex != index + || selectedItemIDs != [item.id] + || selectionAnchorItemID != item.id else { + return + } setActiveSelection(item, at: index, selectedIDs: [item.id], anchorID: item.id) case .activate: - if selectedItemIDs.contains(item.id), selectedItemIDs.count > 1 { + if selectedItemIDSet.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) @@ -900,78 +982,6 @@ 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) @@ -1003,53 +1013,61 @@ final class ClipboardPanelViewModel { } } - func rotateSelectedImageClockwise() { + func rotateSelectedImageClockwise(completion: (() -> Void)? = nil) { 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" + mediaActionQueue.addOperation { [weak self] in + guard let self else { return } + let message: String + if let data = self.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 = self.cacheService.cacheImageSidecarData(fullData, id: item.id), + let thumbnailPath = self.cacheService.cacheImageSidecarData(thumbnailData, id: item.id, fileNamePrefix: "thumb"), + self.store.updateImage( + item.id, + imagePath: fullPath, + thumbnailPath: thumbnailPath, + payloadHash: self.store.hashData(fullData) + ) { + message = "Rotated image" + } else { + message = "Could not rotate image" + } + self.finishMediaAction(message: message, completion: completion) } } - func extractTextFromSelectedImage() { + func extractTextFromSelectedImage(completion: (() -> Void)? = nil) { 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" + mediaActionQueue.addOperation { [weak self] in + guard let self else { return } + let message: String + if let data = self.cacheService.data(for: imagePath), + let image = NSImage(data: data) { + if let text = ImageTextExtractor.normalizedRecognizedText(self.imageTextExtractor(image)) { + message = self.store.updateImageText(item.id, ocrText: text) + ? "Extracted text from image" + : "Could not save image text" + } else { + message = "No text found in image" + } + } else { + message = "Could not read image" + } + self.finishMediaAction(message: message, completion: completion) + } + } + + private func finishMediaAction(message: String, completion: (() -> Void)?) { + DispatchQueue.main.async { [weak self] in + self?.statusMessage = message + completion?() } } @@ -1202,11 +1220,9 @@ final class ClipboardPanelViewModel { let restoredIDs = removals .sorted(by: { $0.index < $1.index }) .map(\.item.id) - let visibleRestoredIDs = restoredIDs.filter { id in - visibleItems.contains { $0.id == id } - } + let visibleRestoredIDs = restoredIDs.filter { visibleIndexByID[$0] != nil } if let firstRestoredID = visibleRestoredIDs.first, - let restoredIndex = visibleItems.firstIndex(where: { $0.id == firstRestoredID }) { + let restoredIndex = visibleIndexByID[firstRestoredID] { setActiveSelection( visibleItems[restoredIndex], at: restoredIndex, @@ -1231,7 +1247,7 @@ final class ClipboardPanelViewModel { } func assignItem(withID id: UUID, to collectionName: String?) { - guard let item = items.first(where: { $0.id == id }) else { return } + guard let item = itemByID[id] else { return } selectedItemID = selectedItem?.id assign(item: item, to: collectionName) } @@ -1268,143 +1284,6 @@ final class ClipboardPanelViewModel { } } - 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) @@ -1575,7 +1454,6 @@ final class ClipboardPanelViewModel { if selectAfterCreate { selectCollection(named: normalizedName) } else { - recomputeVisibleItems() onCollectionsChanged?() } } @@ -1584,14 +1462,23 @@ 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)" + func exportCollection( + named name: String, + to url: URL, + completion: @escaping (Result) -> Void + ) { + mediaActionQueue.addOperation { [weak self] in + guard let self else { return } + let result = Result { try self.store.exportCollection(named: name, to: url) } + DispatchQueue.main.async { [weak self] in + if case .success(let summary) = result, + let normalizedName = ClipboardCollectionDefaults.normalizedName(name) { + let noun = summary.itemCount == 1 ? "clip" : "clips" + self?.statusMessage = "Exported \(normalizedName) Pinboard with \(summary.itemCount) \(noun)" + } + completion(result) + } } - return summary } func reportCollectionExportFailure(_ error: Error) { @@ -1696,7 +1583,7 @@ final class ClipboardPanelViewModel { onCollectionsChanged?() } - if let index = visibleItems.firstIndex(where: { $0.id == item.id }) { + if let index = visibleIndexByID[item.id] { selectedIndex = index selectedItemID = item.id } @@ -1712,7 +1599,7 @@ final class ClipboardPanelViewModel { } let previousSelection = selectedItemID let previousSelectedIDs = selectedItemIDs - let query = searchText.clipboardTrimmed.lowercased() + let query = Self.normalizedSearchValue(searchText) if isStackFilterSelected { let stackedItems = stackItemIDs.compactMap { itemByID[$0] } visibleItems = computeStackVisibleItems(from: stackedItems, query: query) @@ -1738,8 +1625,7 @@ final class ClipboardPanelViewModel { } selectedItemID = selectedItem?.id - let visibleIDs = Set(visibleIndexByID.keys) - let visibleSelectedIDs = previousSelectedIDs.filter { visibleIDs.contains($0) } + let visibleSelectedIDs = previousSelectedIDs.filter { visibleIndexByID[$0] != nil } if previousSelection == nil { selectedItemIDs = selectedItemID.map { [$0] } ?? [] } else if !visibleSelectedIDs.isEmpty { @@ -1749,7 +1635,7 @@ final class ClipboardPanelViewModel { } else { selectedItemIDs = [] } - if let selectionAnchorItemID, !visibleIDs.contains(selectionAnchorItemID) { + if let selectionAnchorItemID, visibleIndexByID[selectionAnchorItemID] == nil { self.selectionAnchorItemID = selectedItemID } } @@ -1832,31 +1718,47 @@ final class ClipboardPanelViewModel { _ indexedItem: IndexedClipboardItem, for item: ClipboardItem, to indexedItemsBySortMode: inout [Int: [IndexedClipboardItem]] + ) { + forEachCategorizingSortMode(including: item) { mode in + indexedItemsBySortMode[mode.rawValue, default: []].append(indexedItem) + } + } + + private func incrementSortModeCounts(for item: ClipboardItem, counts: inout [Int: Int]) { + counts[ClipboardSortMode.mostRecent.rawValue, default: 0] += 1 + counts[ClipboardSortMode.mostUsed.rawValue, default: 0] += 1 + forEachCategorizingSortMode(including: item) { mode in + counts[mode.rawValue, default: 0] += 1 + } + } + + private func forEachCategorizingSortMode( + including item: ClipboardItem, + _ body: (ClipboardSortMode) -> Void ) { switch item.kind { case .image: - indexedItemsBySortMode[ClipboardSortMode.images.rawValue, default: []].append(indexedItem) + body(.images) case .url: - indexedItemsBySortMode[ClipboardSortMode.links.rawValue, default: []].append(indexedItem) + body(.links) case .text, .richText: - indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem) + body(.text) case .code: - indexedItemsBySortMode[ClipboardSortMode.text.rawValue, default: []].append(indexedItem) - indexedItemsBySortMode[ClipboardSortMode.code.rawValue, default: []].append(indexedItem) + body(.text) + body(.code) case .file, .pdf: - indexedItemsBySortMode[ClipboardSortMode.files.rawValue, default: []].append(indexedItem) + body(.files) case .audio: - indexedItemsBySortMode[ClipboardSortMode.audio.rawValue, default: []].append(indexedItem) + body(.audio) case .color: - indexedItemsBySortMode[ClipboardSortMode.colors.rawValue, default: []].append(indexedItem) + body(.colors) case .video: - indexedItemsBySortMode[ClipboardSortMode.videos.rawValue, default: []].append(indexedItem) + body(.videos) case .unknown: break } - if item.isPinned { - indexedItemsBySortMode[ClipboardSortMode.pinned.rawValue, default: []].append(indexedItem) + body(.pinned) } } @@ -2020,7 +1922,12 @@ final class ClipboardPanelViewModel { } private func pruneStackItems() { + guard stackItemsNeedPruning else { return } + stackItemsNeedPruning = false guard !stackItemIDs.isEmpty else { return } + #if DEBUG + debugStackPruneScanCount += 1 + #endif let pruned = stackItemIDs.filter { itemIDSet.contains($0) } if pruned != stackItemIDs { stackItemIDs = pruned @@ -2051,6 +1958,37 @@ final class ClipboardPanelViewModel { return items.filter { matchesSearchQuery($0, query: parsedQuery) } } + private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] { + if let searchMatchCache, searchMatchCache.query == query { + #if DEBUG + debugSearchMatchCacheHitCount += 1 + #endif + return searchMatchCache.indexedItems + } + + let allIndexedItems = indexedItemsBySortMode[ClipboardSortMode.mostRecent.rawValue] ?? [] + let parsedQuery = parseSearchQuery(query) + let matchingItems: [IndexedClipboardItem] + if parsedQuery.isEmpty { + matchingItems = allIndexedItems + } else { + var matches: [IndexedClipboardItem] = [] + matches.reserveCapacity(allIndexedItems.count) + for indexedItem in allIndexedItems { + #if DEBUG + debugSearchItemEvaluationCount += 1 + #endif + if matchesSearchQuery(indexedItem.item, query: parsedQuery) { + matches.append(indexedItem) + } + } + matchingItems = matches + } + + searchMatchCache = SearchMatchCacheEntry(query: query, indexedItems: matchingItems) + return matchingItems + } + private func cachedVisibleItems( query: String, sortMode: ClipboardSortMode, @@ -2062,8 +2000,8 @@ final class ClipboardPanelViewModel { query: query, sortMode: sortMode.rawValue, collectionNameKey: collectionNameKey, - sortModeFilterRawValues: categoryFilters.sortModeRawValues, - collectionNameFilterKeys: categoryFilters.collectionNameKeys + sortModeFilterRawValues: categoryFilters.sortModeRawValues.sorted(), + collectionNameFilterKeys: categoryFilters.collectionNameKeys.sorted() ) if let cached = visibleItemsCache[key] { return cached @@ -2085,9 +2023,8 @@ final class ClipboardPanelViewModel { debugVisibleItemsIndexedLookupCount += 1 #endif } else { - computed = computeVisibleItems( - from: items, - query: query, + computed = filterAndSortVisibleItems( + indexedItemsMatchingSearch(query), sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters @@ -2162,14 +2099,36 @@ final class ClipboardPanelViewModel { categoryFilters: CategoryFilterSelection = .empty ) -> [ClipboardItem] { let parsedQuery = parseSearchQuery(query) - let normalizedCollectionName = ClipboardCollectionDefaults.normalizedName(collectionName) - var filtered: [IndexedClipboardItem] = [] - filtered.reserveCapacity(items.count) + var searchMatches: [IndexedClipboardItem] = [] + searchMatches.reserveCapacity(items.count) for (offset, item) in items.enumerated() { if !parsedQuery.isEmpty, !matchesSearchQuery(item, query: parsedQuery) { continue } + searchMatches.append(IndexedClipboardItem(offset: offset, item: item)) + } + + return filterAndSortVisibleItems( + searchMatches, + sortMode: sortMode, + collectionName: collectionName, + categoryFilters: categoryFilters + ) + } + + private func filterAndSortVisibleItems( + _ indexedItems: [IndexedClipboardItem], + sortMode: ClipboardSortMode, + collectionName: String?, + categoryFilters: CategoryFilterSelection + ) -> [ClipboardItem] { + let normalizedCollectionName = ClipboardCollectionDefaults.normalizedName(collectionName) + var filtered: [IndexedClipboardItem] = [] + filtered.reserveCapacity(indexedItems.count) + + for indexedItem in indexedItems { + let item = indexedItem.item if !categoryFilters.isEmpty { guard itemMatchesCategoryFilters(item, filters: categoryFilters) else { continue @@ -2181,7 +2140,7 @@ final class ClipboardPanelViewModel { } else if !sortMode.includes(item) { continue } - filtered.append(IndexedClipboardItem(offset: offset, item: item)) + filtered.append(indexedItem) } sortIndexedItems( @@ -2224,17 +2183,28 @@ final class ClipboardPanelViewModel { } private func activeCategoryFilterSelection() -> CategoryFilterSelection { - guard !selectedSortModeFilterRawValues.isEmpty || !selectedCollectionNameFilters.isEmpty else { - return .empty + if let categoryFilterSelectionCache { + return categoryFilterSelectionCache } - let sortModeRawValues = selectedSortModeFilterRawValues.sorted() - let collectionKeys = selectedCollectionNameFilters - .compactMap { ClipboardCollectionDefaults.normalizedName($0)?.lowercased() } - .sorted() - return CategoryFilterSelection( - sortModeRawValues: sortModeRawValues, - collectionNameKeys: collectionKeys - ) + + let selection: CategoryFilterSelection + if selectedSortModeFilterRawValues.isEmpty && selectedCollectionNameFilters.isEmpty { + selection = .empty + } else { + selection = CategoryFilterSelection( + sortModeRawValues: selectedSortModeFilterRawValues, + collectionNameKeys: Set( + selectedCollectionNameFilters.compactMap { + ClipboardCollectionDefaults.normalizedName($0)?.lowercased() + } + ) + ) + } + categoryFilterSelectionCache = selection + #if DEBUG + debugCategoryFilterSelectionBuildCount += 1 + #endif + return selection } private func itemMatchesCategoryFilters(_ item: ClipboardItem, filters: CategoryFilterSelection) -> Bool { @@ -2293,61 +2263,50 @@ final class ClipboardPanelViewModel { return lhs.item.lastUsedAt > rhs.item.lastUsedAt } - private func searchableText(for item: ClipboardItem) -> String { - var base = Self.normalizedSearchValue(item.searchableText) - if settings.includeImageTextInSearch, let ocrText = item.ocrText { - base += " \(Self.normalizedSearchValue(ocrText))" - } - return base - } - private func matchesSearchQuery(_ item: ClipboardItem, query: ParsedSearchQuery) -> Bool { + let needsSearchDocument = !query.textTokens.isEmpty + || !query.appTokenGroups.isEmpty + || !query.deviceTokenGroups.isEmpty + || !query.collectionTokenGroups.isEmpty + let document = needsSearchDocument ? searchDocument(for: item) : nil + if !query.textTokens.isEmpty { - let text = searchableText(for: item) + guard let document else { return false } + let text = settings.includeImageTextInSearch ? document.textIncludingOCR : document.text guard query.textTokens.allSatisfy({ text.contains($0) }) else { return false } } if !query.appTokenGroups.isEmpty { - let sourceValues = [item.sourceApp, item.sourceAppBundleId] - .compactMap { $0 } - .map(normalizedStructuredValue) - .filter { !$0.isEmpty } - let sourceText = sourceValues.joined(separator: " ") - guard !sourceValues.isEmpty, + guard let document, !document.sourceValues.isEmpty, query.appTokenGroups.contains(where: { group in if let exactValue = group.exactValue { - return sourceValues.contains(exactValue) + return document.sourceValues.contains(exactValue) } - return group.tokens.allSatisfy { sourceText.contains($0) } + return group.tokens.allSatisfy { document.sourceText.contains($0) } }) else { return false } } if !query.deviceTokenGroups.isEmpty { - let device = normalizedStructuredValue(item.effectiveSourceDeviceName) + guard let document else { return false } guard query.deviceTokenGroups.contains(where: { group in if let exactValue = group.exactValue { - return device == exactValue + return document.device == exactValue } - return group.tokens.allSatisfy { device.contains($0) } + return group.tokens.allSatisfy { document.device.contains($0) } }) else { return false } } if !query.collectionTokenGroups.isEmpty { - guard let collectionName = item.collectionName, - !collectionName.clipboardTrimmed.isEmpty else { - return false - } - let collection = normalizedStructuredValue(collectionName) - guard !collection.isEmpty, + guard let document, !document.collection.isEmpty, query.collectionTokenGroups.contains(where: { group in if let exactValue = group.exactValue { - return collection == exactValue + return document.collection == exactValue } - return group.tokens.allSatisfy { collection.contains($0) } + return group.tokens.allSatisfy { document.collection.contains($0) } }) else { return false } @@ -2372,6 +2331,53 @@ final class ClipboardPanelViewModel { return true } + private func searchDocument(for item: ClipboardItem) -> SearchDocument { + let fingerprint = SearchDocumentFingerprint( + kindRawValue: item.kind.rawValue, + displayText: item.displayText, + payload: item.payload, + customTitle: item.customTitle, + sourceApp: item.sourceApp, + sourceAppBundleID: item.sourceAppBundleId, + collectionName: item.collectionName, + sourceDeviceName: item.sourceDeviceName, + ocrText: item.ocrText + ) + if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint { + #if DEBUG + debugSearchDocumentCacheHitCount += 1 + #endif + return cached + } + + let sourceDeviceName = effectiveSourceDeviceName(for: item) + let text = Self.normalizedSearchValue(item.searchableText) + let normalizedOCR = item.ocrText.map(Self.normalizedSearchValue) ?? "" + let textIncludingOCR = normalizedOCR.isEmpty ? text : "\(text) \(normalizedOCR)" + let sourceValues = [item.sourceApp, item.sourceAppBundleId] + .compactMap { $0 } + .map(normalizedStructuredValue) + .filter { !$0.isEmpty } + let document = SearchDocument( + fingerprint: fingerprint, + text: text, + textIncludingOCR: textIncludingOCR, + sourceValues: sourceValues, + sourceText: sourceValues.joined(separator: " "), + device: normalizedStructuredValue(sourceDeviceName), + collection: item.collectionName.map(normalizedStructuredValue) ?? "" + ) + searchDocumentsByItemID[item.id] = document + #if DEBUG + debugSearchDocumentBuildCount += 1 + #endif + return document + } + + private func effectiveSourceDeviceName(for item: ClipboardItem) -> String { + ClipboardItem.normalizedDeviceName(item.sourceDeviceName) ?? Self.fallbackSourceDeviceName + } + private func parseSearchQuery(_ query: String) -> ParsedSearchQuery { var parsed = ParsedSearchQuery() for part in searchParts(from: query) { @@ -2691,6 +2697,32 @@ final class ClipboardPanelViewModel { return nil } + private func thumbnailRequestKey(for item: ClipboardItem) -> ThumbnailRequestKey? { + let source: String? + switch item.kind { + case .url, .image: + source = item.thumbnailPath + case .pdf, .file, .video: + source = item.payload + case .text, .unknown, .audio, .richText, .color, .code: + return nil + } + return ThumbnailRequestKey(itemID: item.id, kindRawValue: item.kind.rawValue, source: source) + } + + private func finishThumbnailRequest(_ requestKey: ThumbnailRequestKey, thumbnail: NSImage?) { + thumbnailRequestLock.lock() + let completions = thumbnailCompletionsByRequest.removeValue(forKey: requestKey) ?? [] + thumbnailRequestLock.unlock() + guard !completions.isEmpty else { return } + + DispatchQueue.main.async { + for completion in completions { + completion(thumbnail) + } + } + } + private static func statusKindName(_ kind: ClipboardItemKind) -> String { let name = kind.displayName return name == name.uppercased() ? name : name.capitalized diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index fb987a7..d3a9e0d 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -8,11 +8,12 @@ private final class TopAlignedSettingsDocumentView: NSView { 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 + static let windowSize = NSSize(width: 620, height: 520) + static let minimumWindowSize = NSSize(width: 560, height: 440) + static let settingsContentMinimumWidth: CGFloat = 440 + static let settingsLabelWidth: CGFloat = 128 } + private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"] private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled." private static let allowedContentTypesUpdatedMessage = "Allowed content types updated." @@ -20,9 +21,23 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let store: ClipboardStore private let cacheService: ClipboardCacheService private let cloudSyncService: ClipboardCloudSyncServicing + private let dataOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "clipbored.settings.data-operations" + queue.qualityOfService = .userInitiated + queue.maxConcurrentOperationCount = 1 + return queue + }() + private var dataOperationInProgress = false private var window: NSWindow? private var cachedCloudSyncStatus: ClipboardCloudSyncStatus? private let tabView = NSTabView() + private let tabSelector = NSSegmentedControl( + labels: SettingsWindowController.tabTitles, + trackingMode: .selectOne, + target: nil, + action: nil + ) private let historyLabel = NSTextField(labelWithString: "") private let historyStepper = NSStepper() @@ -64,6 +79,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let iCloudRestoreButton = NSButton() private let iCloudRevealButton = NSButton() private let cloudSyncStatusLabel = NSTextField(labelWithString: "") + private let exportArchiveButton = NSButton() + private let importArchiveButton = NSButton() #if DEBUG private var debugFullRefreshCountValue = 0 @@ -118,24 +135,44 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private func makeContentView() -> NSView { tabView.translatesAutoresizingMaskIntoConstraints = false + tabView.tabViewType = .noTabsNoBorder 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())) + + tabSelector.translatesAutoresizingMaskIntoConstraints = false + tabSelector.target = self + tabSelector.action = #selector(settingsTabChanged(_:)) + tabSelector.selectedSegment = 0 + tabSelector.segmentStyle = .rounded let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + container.addSubview(tabSelector) container.addSubview(tabView) NSLayoutConstraint.activate([ + tabSelector.topAnchor.constraint(equalTo: container.topAnchor, constant: 10), + tabSelector.centerXAnchor.constraint(equalTo: container.centerXAnchor), + tabSelector.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 12), + tabSelector.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12), tabView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), tabView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), - tabView.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + tabView.topAnchor.constraint(equalTo: tabSelector.bottomAnchor, constant: 10), tabView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12) ]) return container } + @objc private func settingsTabChanged(_ sender: NSSegmentedControl) { + let index = sender.selectedSegment + guard index >= 0, index < tabView.numberOfTabViewItems else { return } + tabView.selectTabViewItem(at: index) + } + private func tab(_ title: String, _ view: NSView) -> NSTabViewItem { let item = NSTabViewItem(identifier: title) item.label = title @@ -159,16 +196,18 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel content.translatesAutoresizingMaskIntoConstraints = false documentView.addSubview(content) scrollView.documentView = documentView + let documentFillsViewport = documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.bottomAnchor) + let documentContainsContent = documentView.bottomAnchor.constraint(greaterThanOrEqualTo: content.bottomAnchor) NSLayoutConstraint.activate([ 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), + documentFillsViewport, + documentContainsContent, 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) + content.topAnchor.constraint(equalTo: documentView.topAnchor) ]) return scrollView } @@ -354,6 +393,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive)) configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive)) configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile)) + configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive)) + configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive)) 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([ @@ -370,8 +411,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel section("Archive", [ archiveLabel, row([ - button("Export Archive...", #selector(exportClipboardArchive)), - button("Import Archive...", #selector(importClipboardArchive)) + exportArchiveButton, + importArchiveButton ]) ]), section("Data", [ @@ -387,8 +428,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel let stack = NSStackView(views: views) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 18 - stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18) + stack.spacing = 12 + stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) return stack } @@ -398,7 +439,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel let stack = NSStackView(views: [titleLabel] + views) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 8 + stack.spacing = 7 let minimumWidth = stack.widthAnchor.constraint(greaterThanOrEqualToConstant: Metrics.settingsContentMinimumWidth) minimumWidth.priority = .defaultLow minimumWidth.isActive = true @@ -513,7 +554,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private func modifierButton(_ title: String, _ tag: Int) -> NSButton { let control = NSButton() configureCheckbox(control, title: title, action: #selector(shortcutChanged(_:))) - control.toolTip = modifierTooltip(title) + let modifierName = modifierTooltip(title) + control.toolTip = modifierName + control.setAccessibilityLabel("\(modifierName) modifier") + control.setAccessibilityHelp("Include or remove the \(modifierName) key from this shortcut.") control.tag = tag return control } @@ -572,7 +616,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off case .pollProfile: select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue) - case .panelLayout: + case .panelSide: select(panelSidePopup, rawValue: settings.panelSide.rawValue) case .showMenuBarIcon: refreshVisibilityControls() @@ -859,7 +903,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ) cloudSyncStatusLabel.stringValue = presentation.message cloudSyncStatusLabel.textColor = presentation.textColor - let cloudActionsEnabled = settings.iCloudSyncEnabled && cloudStatus?.isAvailable == true + let cloudActionsEnabled = settings.iCloudSyncEnabled + && cloudStatus?.isAvailable == true + && !dataOperationInProgress iCloudSyncNowButton.isEnabled = cloudActionsEnabled iCloudRestoreButton.isEnabled = cloudActionsEnabled iCloudRevealButton.isEnabled = cloudActionsEnabled @@ -1244,11 +1290,16 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel 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) + setDataStatus("Exporting archive…") + performDataOperation({ [store] in + Result { try store.exportArchive(to: url) } + }) { [weak self] result in + switch result { + case .success(let summary): + self?.setDataStatus("Exported \(summary.itemCount) clips and \(summary.sidecarCount) attachments.") + case .failure(let error): + self?.setDataStatus(error.localizedDescription) + } } } @@ -1261,15 +1312,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel 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("Importing archive…") + performDataOperation({ [store] in + Result { try store.importArchive(from: url) } + }) { [weak self] result in + switch result { + case .success(let summary): + 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." + } + self?.setDataStatus(message) + case .failure(let error): + self?.setDataStatus(error.localizedDescription) } - setDataStatus(message) - } catch { - setDataStatus(error.localizedDescription) } } @@ -1279,12 +1335,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel 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)") + settings.setCloudSyncStatus(message: "Syncing with iCloud…") + performDataOperation({ [cloudSyncService, store] in + Result { + let summary = try cloudSyncService.push(store: store) + return (summary, cloudSyncService.status()) + } + }) { [weak self] result in + switch result { + case .success(let (summary, status)): + self?.cachedCloudSyncStatus = status + self?.settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips and \(summary.sidecarCount) attachments to iCloud.") + case .failure(let error): + self?.settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } } } @@ -1294,16 +1358,45 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel 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: "Restoring from iCloud…") + performDataOperation({ [cloudSyncService, store] in + Result { + let summary = try cloudSyncService.pull(store: store) + return (summary, cloudSyncService.status()) + } + }) { [weak self] result in + switch result { + case .success(let (summary, status)): + self?.cachedCloudSyncStatus = 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." + } + self?.settings.setCloudSyncStatus(message: message) + case .failure(let error): + self?.settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") + } + } + } + + private func performDataOperation( + _ operation: @escaping () -> ResultValue, + completion: @escaping (ResultValue) -> Void + ) { + dataOperationInProgress = true + exportArchiveButton.isEnabled = false + importArchiveButton.isEnabled = false + refreshCloudSyncControls(refreshStatus: false) + dataOperationQueue.addOperation { [weak self] in + let result = operation() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.dataOperationInProgress = false + self.exportArchiveButton.isEnabled = true + self.importArchiveButton.isEnabled = true + completion(result) + self.refreshCloudSyncControls(refreshStatus: false) } - settings.setCloudSyncStatus(message: message) - } catch { - settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)") } } @@ -1736,6 +1829,36 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel shortcutStatusLabel.stringValue } + var debugOpenShortcutModifierAccessibilityLabels: [String] { + guard let controls = openShortcutControls else { return [] } + return [controls.command, controls.option, controls.control, controls.shift] + .compactMap { $0.accessibilityLabel() } + } + + var debugOpenShortcutModifierAccessibilityHelps: [String] { + guard let controls = openShortcutControls else { return [] } + return [controls.command, controls.option, controls.control, controls.shift] + .compactMap { $0.accessibilityHelp() } + } + + var debugSettingsContentView: NSView? { + window?.contentView + } + + func debugSelectSettingsTab(at index: Int) { + guard index >= 0, index < tabView.numberOfTabViewItems else { return } + tabSelector.selectedSegment = index + tabView.selectTabViewItem(at: index) + window?.contentView?.layoutSubtreeIfNeeded() + } + + func debugPrepareWindowForSnapshot() { + window?.setFrameOrigin(.zero) + window?.orderFront(nil) + window?.contentView?.layoutSubtreeIfNeeded() + window?.contentView?.displayIfNeeded() + } + var debugFullRefreshCount: Int { debugFullRefreshCountValue } diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index e7fa4d0..00c8039 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon import XCTest @testable import ClipBored @@ -140,59 +141,6 @@ final class ClipboardPanelControllerTests: XCTestCase { 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() { let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982) let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861) @@ -234,10 +182,10 @@ final class ClipboardPanelControllerTests: XCTestCase { controller.debugSetSearchFieldText("stale query") drainMainQueue() XCTAssertEqual(controller.debugSearchFieldText, "stale query") - XCTAssertEqual(controller.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1) XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips") XCTAssertTrue(controller.debugSearchFieldIsVisible) - XCTAssertFalse(controller.debugSearchIconButtonIsVisible) + XCTAssertTrue(controller.debugSearchIconButtonIsVisible) controller.show(preferredScreen: screen) defer { controller.hide(immediate: true) } @@ -277,22 +225,6 @@ final class ClipboardPanelControllerTests: XCTestCase { 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) @@ -496,6 +428,32 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command)) } + func testSettingsShortcutMatchesOnlyItsExactLocalBinding() { + let binding = AppConfiguration.defaultSettingsShortcut + + XCTAssertTrue( + ClipboardPanelController.matchesShortcut( + keyCode: UInt16(kVK_ANSI_Comma), + modifiers: .command, + binding: binding + ) + ) + XCTAssertFalse( + ClipboardPanelController.matchesShortcut( + keyCode: UInt16(kVK_ANSI_Comma), + modifiers: [.command, .shift], + binding: binding + ) + ) + XCTAssertFalse( + ClipboardPanelController.matchesShortcut( + keyCode: UInt16(kVK_ANSI_Period), + modifiers: .command, + binding: binding + ) + ) + } + func testModifiedShortcutsMapToPanelActions() { XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText) XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack) diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 532759a..62ad150 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -206,6 +206,205 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.debugCollectionCountIndexedLookupCount, 1) } + func testSearchMatchesAreEvaluatedOnceAcrossVisibleItemsCountsAndCategoryChanges() { + let settings = makeSettings() + settings.maxHistoryItems = 120 + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + + for index in 0..<100 { + var item = makeTextItem( + index.isMultiple(of: 5) ? "shared search needle \(index)" : "unmatched history \(index)", + createdAt: Date(timeIntervalSince1970: Double(index)) + ) + if index.isMultiple(of: 2) { + item.collectionName = "Client Work" + } + store.upsert(item) + } + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 100) + viewModel.debugResetVisibleItemsPerformanceCounters() + + viewModel.searchText = "needle" + XCTAssertEqual(viewModel.visibleItems.count, 20) + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100) + XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100) + + let counts = viewModel.collectionCountSummary() + XCTAssertEqual(counts.count(for: .text), 20) + XCTAssertEqual(counts.count(named: "Client Work"), 10) + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100) + XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 1) + + viewModel.selectSortMode(.text, extending: true) + viewModel.selectSortMode(.links, extending: true) + + XCTAssertEqual(viewModel.visibleItems.count, 20) + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100) + XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 3) + + viewModel.searchText = "shared" + + XCTAssertEqual(viewModel.visibleItems.count, 20) + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 200) + XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100) + XCTAssertEqual(viewModel.debugSearchDocumentCacheHitCount, 100) + } + + func testEquivalentDiacriticSearchReusesVisibleAndCollectionCountCaches() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("Résumé draft", createdAt: Date(timeIntervalSince1970: 100))) + store.upsert(makeTextItem("Meeting note", createdAt: Date(timeIntervalSince1970: 200))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 2) + viewModel.debugResetVisibleItemsPerformanceCounters() + + viewModel.searchText = "resume" + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"]) + _ = viewModel.collectionCountSummary() + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2) + XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1) + + viewModel.searchText = " RÉSUMÉ " + _ = viewModel.collectionCountSummary() + + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"]) + XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2) + XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1) + } + + func testCategoryFilterSelectionIsBuiltOncePerMutationAndReusedForChipStateQueries() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("category state", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + viewModel.debugResetVisibleItemsPerformanceCounters() + + viewModel.selectSortMode(.text, extending: true) + XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1) + + for _ in 0..<20 { + for mode in ClipboardSortMode.allCases { + _ = viewModel.isSortModeCategorySelected(mode) + } + _ = viewModel.isCollectionCategorySelected(named: "Client Work") + _ = viewModel.canShowVisibleItemsInClipboard + } + + XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1) + } + + func testRepeatedRecomputesDoNotRescanUnchangedStackMembership() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("stacked needle", createdAt: Date(timeIntervalSince1970: 100))) + store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 2) + viewModel.selectItem(at: 1) + viewModel.toggleSelectedStackMembership() + viewModel.debugResetVisibleItemsPerformanceCounters() + + viewModel.searchText = "needle" + viewModel.clearSearch() + viewModel.selectSortMode(.text) + viewModel.selectSortMode(.mostRecent) + + XCTAssertEqual(viewModel.stackCount, 1) + XCTAssertEqual(viewModel.debugStackPruneScanCount, 0) + } + + func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("hover target", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + var selectedIndexCallbackCount = 0 + var selectedItemsCallbackCount = 0 + viewModel.onSelectedIndexChanged = { _ in selectedIndexCallbackCount += 1 } + viewModel.onSelectedItemsChanged = { selectedItemsCallbackCount += 1 } + + viewModel.selectItem(at: 0, mode: .hover) + let callbackCountsAfterFirstHover = (selectedIndexCallbackCount, selectedItemsCallbackCount) + viewModel.selectItem(at: 0, mode: .hover) + + XCTAssertEqual(selectedIndexCallbackCount, callbackCountsAfterFirstHover.0) + XCTAssertEqual(selectedItemsCallbackCount, callbackCountsAfterFirstHover.1) + } + + func testAsyncThumbnailLoadingRunsOffMainCoalescesRequestsAndCompletesOnMain() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let loaderStarted = expectation(description: "thumbnail loader started") + let completionsFinished = expectation(description: "thumbnail completions") + completionsFinished.expectedFulfillmentCount = 2 + let loaderGate = DispatchSemaphore(value: 0) + let loaderStateLock = NSLock() + var loaderCallCount = 0 + var loaderRanOnMain = true + + let viewModel = ClipboardPanelViewModel( + store: store, + settings: settings, + cacheService: cacheService, + thumbnailLoader: { _ in + loaderStateLock.lock() + loaderCallCount += 1 + loaderRanOnMain = Thread.isMainThread + loaderStateLock.unlock() + loaderStarted.fulfill() + _ = loaderGate.wait(timeout: .now() + 2) + return NSImage(size: NSSize(width: 20, height: 20)) + } + ) + var item = makeTextItem("thumbnail request", createdAt: Date(timeIntervalSince1970: 100)) + item.kind = .image + item.thumbnailPath = "/tmp/thumbnail-request.png" + var completionMainThreadValues: [Bool] = [] + + viewModel.loadThumbnail(for: item) { image in + completionMainThreadValues.append(Thread.isMainThread) + XCTAssertNotNil(image) + completionsFinished.fulfill() + } + wait(for: [loaderStarted], timeout: 1) + + viewModel.loadThumbnail(for: item) { image in + completionMainThreadValues.append(Thread.isMainThread) + XCTAssertNotNil(image) + completionsFinished.fulfill() + } + loaderGate.signal() + wait(for: [completionsFinished], timeout: 2) + + loaderStateLock.lock() + let finalLoaderCallCount = loaderCallCount + let finalLoaderRanOnMain = loaderRanOnMain + loaderStateLock.unlock() + XCTAssertEqual(finalLoaderCallCount, 1) + XCTAssertFalse(finalLoaderRanOnMain) + XCTAssertEqual(completionMainThreadValues, [true, true]) + } + func testComputeVisibleItemsFiltersColorClipsAndStructuredType() { let settings = makeSettings() let store = makeStore(settings: settings) @@ -1141,6 +1340,50 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(collectionCallbackCount, 0) } + func testCreatingUnselectedCollectionUpdatesChromeWithoutReloadingVisibleItems() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("visible note", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + var visibleCallbackCount = 0 + var collectionCallbackCount = 0 + viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 } + viewModel.onCollectionsChanged = { collectionCallbackCount += 1 } + + viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8", selectAfterCreate: false) + + XCTAssertEqual(viewModel.collectionNames, ["Client Work"]) + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["visible note"]) + XCTAssertEqual(visibleCallbackCount, 0) + XCTAssertEqual(collectionCallbackCount, 1) + } + + func testExternalCollectionSettingsChangeUpdatesChromeWithoutReloadingVisibleItems() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + store.upsert(makeTextItem("visible note", createdAt: Date(timeIntervalSince1970: 100))) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + var visibleCallbackCount = 0 + var collectionCallbackCount = 0 + viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 } + viewModel.onCollectionsChanged = { collectionCallbackCount += 1 } + + settings.ensureCollection(named: "Client Work", colorHex: "#0A9EB8") + + XCTAssertEqual(viewModel.collectionNames, ["Client Work"]) + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["visible note"]) + XCTAssertEqual(visibleCallbackCount, 0) + XCTAssertEqual(collectionCallbackCount, 1) + } + func testSettingsChangesRefreshPanelSortAndImageTextSearch() { let settings = makeSettings() let cacheService = makeCacheService() @@ -1190,6 +1433,44 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.collectionCount(for: .images), 1) } + func testImageTextSearchSettingSkipsReloadWhenActiveQueryHasNoTextTokens() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let image = ClipboardItem( + id: UUID(), + kind: .image, + displayText: "Screenshot", + payload: "screenshot-path", + payloadHash: hash("screenshot-path"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil, + ocrText: "Receipt total" + ) + store.upsert(image) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 1) + var visibleCallbackCount = 0 + viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 } + + settings.includeImageTextInSearch = true + XCTAssertEqual(visibleCallbackCount, 0) + + viewModel.searchText = "type:image" + XCTAssertEqual(visibleCallbackCount, 1) + visibleCallbackCount = 0 + settings.includeImageTextInSearch = false + + XCTAssertEqual(viewModel.visibleItems.map(\.id), [image.id]) + XCTAssertEqual(visibleCallbackCount, 0) + } + func testAdjacentCollectionNavigationWrapsThroughPinboards() { let settings = makeSettings() let cacheService = makeCacheService() @@ -1226,142 +1507,6 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "No collections") } - func testCompactModeToggleIsRemovedAndDoesNotPersist() { - let settings = makeSettings() - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - - XCTAssertFalse(viewModel.isCompactModeEnabled) - - viewModel.toggleCompactMode() - - XCTAssertFalse(settings.compactMode) - XCTAssertFalse(viewModel.isCompactModeEnabled) - XCTAssertEqual(viewModel.statusMessage, "Compact Mode was removed") - - viewModel.toggleCompactMode() - - XCTAssertFalse(settings.compactMode) - XCTAssertFalse(viewModel.isCompactModeEnabled) - XCTAssertEqual(viewModel.statusMessage, "Compact Mode was removed") - } - - func testCreateTextClipAddsSearchableSelectedItem() { - let settings = makeSettings() - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - viewModel.sortMode = .images - viewModel.searchText = "old query" - let now = Date(timeIntervalSince1970: 1_800) - - let created = viewModel.createTextClip(" Launch note ", now: now) - store.flushPersistenceForTesting() - waitForVisibleItems(in: viewModel, count: 1) - - XCTAssertEqual(created?.kind, .text) - XCTAssertEqual(created?.payload, "Launch note") - XCTAssertEqual(created?.displayText, "Launch note") - XCTAssertEqual(created?.sourceApp, AppConfiguration.appName) - XCTAssertEqual(created?.useCount, 0) - XCTAssertEqual(created?.createdAt, now) - XCTAssertEqual(viewModel.sortMode, .text) - XCTAssertEqual(viewModel.searchText, "") - XCTAssertEqual(viewModel.selectedItem?.id, created?.id) - XCTAssertEqual(viewModel.visibleItems.first?.payload, "Launch note") - XCTAssertEqual(viewModel.statusMessage, "Created text clip") - } - - func testCreateTextClipBatchesFilterResetIntoSingleVisibleReload() { - let settings = makeSettings() - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - let seed = makeTextItem("stack seed", createdAt: Date(timeIntervalSince1970: 100)) - store.upsert(seed) - store.flushPersistenceForTesting() - - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - waitForVisibleItems(in: viewModel, count: 1) - viewModel.selectItem(at: 0) - viewModel.toggleSelectedStackMembership() - viewModel.sortMode = .images - viewModel.selectStack() - viewModel.searchText = "old query" - XCTAssertTrue(viewModel.isStackFilterSelected) - XCTAssertEqual(viewModel.sortMode, .images) - XCTAssertEqual(viewModel.searchText, "old query") - - var visibleCallbackCount = 0 - var searchTextCallbacks: [String] = [] - var sortCallbacks: [ClipboardSortMode] = [] - var collectionCallbackCount = 0 - var stackCallbackCount = 0 - viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 } - viewModel.onSearchTextChanged = { searchTextCallbacks.append($0) } - viewModel.onSortModeChanged = { sortCallbacks.append($0) } - viewModel.onCollectionsChanged = { collectionCallbackCount += 1 } - viewModel.onStackChanged = { stackCallbackCount += 1 } - - let created = viewModel.createTextClip("Launch note", now: Date(timeIntervalSince1970: 200)) - - XCTAssertEqual(created?.payload, "Launch note") - XCTAssertFalse(viewModel.isStackFilterSelected) - XCTAssertEqual(viewModel.sortMode, .text) - XCTAssertEqual(viewModel.searchText, "") - XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Launch note", "stack seed"]) - XCTAssertEqual(viewModel.selectedItem?.id, created?.id) - XCTAssertEqual(visibleCallbackCount, 1) - XCTAssertEqual(searchTextCallbacks, [""]) - XCTAssertEqual(sortCallbacks, [.text]) - XCTAssertEqual(collectionCallbackCount, 0) - XCTAssertEqual(stackCallbackCount, 0) - XCTAssertEqual(viewModel.statusMessage, "Created text clip") - } - - func testCreateTextClipAddsItemToActiveCollection() { - let settings = makeSettings() - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8") - - let created = viewModel.createTextClip("Pinboard note", now: Date(timeIntervalSince1970: 1_900)) - store.flushPersistenceForTesting() - waitForVisibleItems(in: viewModel, count: 1) - - XCTAssertEqual(created?.collectionName, "Client Work") - XCTAssertEqual(store.items.first?.collectionName, "Client Work") - XCTAssertEqual(viewModel.selectedCollectionName, "Client Work") - XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Pinboard note"]) - XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1) - } - - func testCreateTextClipMergesDuplicateIntoActiveCollection() { - let settings = makeSettings() - settings.pruneDuplicates = true - let cacheService = makeCacheService() - let store = makeStore(settings: settings, cacheService: cacheService) - var existing = makeTextItem("Reusable note", createdAt: Date(timeIntervalSince1970: 100)) - existing.payloadHash = store.hashString(existing.payload) - store.upsert(existing) - store.flushPersistenceForTesting() - let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) - waitForVisibleItems(in: viewModel, count: 1) - viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8") - - let created = viewModel.createTextClip("Reusable note", now: Date(timeIntervalSince1970: 2_000)) - store.flushPersistenceForTesting() - waitForVisibleItems(in: viewModel, count: 1) - - XCTAssertEqual(store.items.count, 1) - XCTAssertEqual(created?.id, existing.id) - XCTAssertEqual(store.items.first?.id, existing.id) - XCTAssertEqual(store.items.first?.collectionName, "Client Work") - XCTAssertEqual(viewModel.selectedItem?.id, existing.id) - XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Reusable note"]) - } - func testUpdateCollectionRenamesAssignedItemsAndColor() { let settings = makeSettings() let cacheService = makeCacheService() @@ -2419,6 +2564,10 @@ final class ClipboardPanelViewModelTests: XCTestCase { let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) waitForVisibleItems(in: viewModel, count: 1) + viewModel.searchText = "draft" + XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id]) + viewModel.clearSearch() + XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note") viewModel.updateSelectedText(to: "final launch note") store.flushPersistenceForTesting() @@ -2530,7 +2679,9 @@ final class ClipboardPanelViewModelTests: XCTestCase { let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) waitForVisibleItems(in: viewModel, count: 1) - viewModel.rotateSelectedImageClockwise() + let completion = expectation(description: "Rotate image off the main thread") + viewModel.rotateSelectedImageClockwise(completion: completion.fulfill) + wait(for: [completion], timeout: 2) store.flushPersistenceForTesting() XCTAssertEqual(viewModel.statusMessage, "Rotated image") @@ -2597,7 +2748,9 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) waitForVisibleItems(in: viewModel, count: 1) - viewModel.extractTextFromSelectedImage() + let completion = expectation(description: "Extract image text off the main thread") + viewModel.extractTextFromSelectedImage(completion: completion.fulfill) + wait(for: [completion], timeout: 2) store.flushPersistenceForTesting() XCTAssertEqual(extractionCount, 1) @@ -2650,7 +2803,9 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) waitForVisibleItems(in: viewModel, count: 1) - viewModel.extractTextFromSelectedImage() + let completion = expectation(description: "Finish empty image text extraction") + viewModel.extractTextFromSelectedImage(completion: completion.fulfill) + wait(for: [completion], timeout: 2) store.flushPersistenceForTesting() XCTAssertEqual(viewModel.statusMessage, "No text found in image") @@ -2737,10 +2892,17 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "Copied") + var statusCallbacks: [String] = [] + var captureStatusCallbackCount = 0 + viewModel.onStatusMessageChanged = { statusCallbacks.append($0) } + viewModel.onCaptureStatusChanged = { captureStatusCallbackCount += 1 } + settings.setCaptureStatus(message: "Capture status updated while panel is open") RunLoop.main.run(until: Date().addingTimeInterval(0.05)) XCTAssertEqual(viewModel.statusMessage, "") + XCTAssertEqual(statusCallbacks, [""]) + XCTAssertEqual(captureStatusCallbackCount, 1) } private func makeSettings() -> SettingsModel { diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 36c1ff4..cf6306f 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -47,12 +47,51 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(view.isSearchFieldEditing) } + func testSearchUsesOneLeadingAnchoredLiquidGlassControlInBothStates() { + let fixture = makePanelFixture() + fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 760), display: true) + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSearchGlassMaterial, .popover) + XCTAssertEqual(fixture.view.debugSearchGlassBlendingMode, .withinWindow) + XCTAssertEqual(fixture.view.debugSearchGlassCornerRadius, 15, accuracy: 0.5) + XCTAssertGreaterThan(fixture.view.debugSearchGlassBorderAlpha, 0.35) + XCTAssertGreaterThan(fixture.view.debugSearchGlassTintAlpha, 0.05) + XCTAssertTrue(fixture.view.debugSearchFieldUsesTransparentChrome) + XCTAssertTrue( + fixture.view.debugSearchIconIsPinnedToLeadingEdge, + "Icon leading offset: \(fixture.view.debugSearchIconLeadingOffset)" + ) + XCTAssertTrue( + fixture.view.debugSearchControlIsLeadingAlignedInToolbar, + "Toolbar leading offset: \(fixture.view.debugSearchControlToolbarLeadingOffset)" + ) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) + + fixture.view.focusSearchField() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSearchGlassMaterial, .popover) + XCTAssertEqual(fixture.view.debugSearchGlassCornerRadius, 15, accuracy: 0.5) + XCTAssertTrue(fixture.view.debugSearchFieldUsesTransparentChrome) + XCTAssertTrue(fixture.view.debugSearchFieldIsCenteredInGlass) + XCTAssertTrue( + fixture.view.debugSearchControlIsLeadingAlignedInToolbar, + "Toolbar leading offset: \(fixture.view.debugSearchControlToolbarLeadingOffset)" + ) + XCTAssertTrue(fixture.view.debugSearchIconIsPinnedToLeadingEdge) + XCTAssertGreaterThanOrEqual(fixture.view.debugSearchTextLeadingGap, 4) + XCTAssertGreaterThanOrEqual(fixture.view.debugSearchEditorLeadingGap, 4) + XCTAssertEqual(fixture.view.debugSearchControlTrailingActionGap, 12, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 238, accuracy: 0.5) + } + func testSearchFieldExpandsWhileFocusedEvenWhenEmptyAndCollapsesWhenIdle() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Search focus polish", store: fixture.store)) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSearchFieldText, "") fixture.view.focusSearchField() @@ -60,7 +99,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertTrue(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchControlHeight, 30, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchIconButtonHeight, 30, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") @@ -93,7 +132,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertFalse(fixture.view.isSearchFieldEditing) - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") fixture.view.debugSetSearchFieldText("") @@ -105,6 +144,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) + XCTAssertEqual(fixture.view.debugSearchIconButtonCornerRadius, 15, accuracy: 0.5) } func testWhitespaceOnlySearchTextKeepsFieldExpandedUntilCleared() { @@ -128,7 +168,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertFalse(fixture.view.isSearchFieldEditing) XCTAssertEqual(fixture.view.debugSearchFieldText, " ") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Whitespace search presentation"]) @@ -137,7 +177,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugSearchFieldText, " ") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertTrue(fixture.view.clearSearchForKeyboardCancel()) drainMainQueue() @@ -145,9 +185,9 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.isSearchFieldEditing) XCTAssertEqual(fixture.view.debugSearchFieldText, "") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertFalse(fixture.view.debugSearchIconButtonIsVisible) + XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) drainMainQueue() @@ -170,7 +210,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Alpha planning note"]) fixture.view.prepareForShow() @@ -192,24 +232,21 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.isSearchFieldEditing) XCTAssertEqual(fixture.view.debugSearchFieldText, "q") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertFalse(fixture.view.debugSearchIconButtonIsVisible) + XCTAssertTrue(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()) + view.focusSearch() XCTAssertTrue(view.isSearchFieldEditing) - XCTAssertEqual(view.debugSearchFilterMenuPresentationCount, 0) - XCTAssertFalse(view.focusSearchOrShowFilters()) + view.focusSearch() - XCTAssertEqual(view.debugSearchFilterMenuPresentationCount, 0) XCTAssertTrue(view.isSearchFieldEditing) } @@ -231,7 +268,7 @@ final class ClipboardPanelViewTests: XCTestCase { } } - func testShelfChromeKeepsSearchAboveSideIconCategoryRail() { + func testShelfChromeKeepsToolbarAlignedAboveSideCategoryRail() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Compact shelf chrome", store: fixture.store)) drainMainQueue() @@ -242,31 +279,23 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndActions) XCTAssertTrue(fixture.view.debugCollectionRailIsBesideCardRail) XCTAssertTrue(fixture.view.debugSearchFieldSharesAnimatedContainerWithIcon) - XCTAssertEqual(fixture.view.debugSearchAnimationDuration, 0.14, accuracy: 0.01) + XCTAssertEqual(fixture.view.debugSearchAnimationDuration, 0.20, accuracy: 0.01) XCTAssertEqual(fixture.view.debugSearchFieldWidth, 30, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "") XCTAssertFalse(fixture.view.debugSearchFieldIsVisible) XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) XCTAssertEqual(fixture.view.debugCollectionStackOrientation, .vertical) XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) - XCTAssertGreaterThan(fixture.view.debugCollectionRailContentTopInset, 0) - XCTAssertEqual( - fixture.view.debugCollectionRailContentFrameInPanel.midY, - fixture.view.debugCollectionRailFrameInPanel.midY, - accuracy: 1 + XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 200) + XCTAssertLessThanOrEqual( + fixture.view.debugCollectionRailFrameInPanel.maxX + 8, + fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 ) - 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(alpha, 0.06, accuracy: 0.01) } XCTAssertEqual(fixture.view.debugToolbarButtonBorderWidths, [0.5, 0.5]) @@ -274,13 +303,13 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips") XCTAssertTrue(fixture.view.debugSearchFieldIsVisible) - XCTAssertFalse(fixture.view.debugSearchIconButtonIsVisible) + XCTAssertTrue(fixture.view.debugSearchIconButtonIsVisible) } - func testCollectionRailStaysOnSideAcrossSelection() { + func testCollectionRailStaysBesideCardsAcrossSelection() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Centered text category", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/centered", store: fixture.store)) @@ -297,7 +326,7 @@ final class ClipboardPanelViewTests: XCTestCase { assertCollectionRailIsSideIconRail(in: fixture) } - func testHoveringCategoryPreviewsThenRestoresOriginalCategoryWhenLeavingRail() { + func testHoveringCategoryOnlyChangesChromeAndDoesNotMutateFiltering() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Plain text clip", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/preview", store: fixture.store)) @@ -312,16 +341,16 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.url]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) fixture.view.debugUnhoverCollectionChip(.links) fixture.view.debugHoverCollectionChip(.text) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Text") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.text]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) fixture.view.debugUnhoverCollectionChip(.text) drainMainQueue() @@ -331,7 +360,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) } - func testClickingHoveredCategoryCommitsPreviewAfterMouseLeaves() { + func testClickingHoveredCategorySelectsItWithoutHoverSideEffects() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Commit text clip", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/commit", store: fixture.store)) @@ -341,8 +370,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.view.debugHoverCollectionChip(.images) drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Images") - XCTAssertEqual(fixture.viewModel.visibleItems.map(\.kind), [.image]) + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.kind)), Set([.text, .url, .image])) fixture.view.debugMouseDownCollectionChip(.images) fixture.view.debugUnhoverCollectionChip(.images) @@ -360,7 +389,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertLessThanOrEqual(fixture.view.debugCardRailTopGap, 96) + XCTAssertLessThanOrEqual(fixture.view.debugCardRailTopGap, 120) } func testSideShelfUsesRowsAndExpandsSelection() { @@ -372,14 +401,11 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugPanelLayout, ClipboardPanelLayout.vertical.title) + XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") XCTAssertEqual(fixture.view.debugItemsStackOrientation, .vertical) XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-row"]) XCTAssertGreaterThan(fixture.view.debugCardSizes[0].width, fixture.view.debugCardSizes[0].height) - XCTAssertLessThanOrEqual( - fixture.view.debugCollectionRailFrameInPanel.maxX + 8, - fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 - ) + XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes[0].width, 240) XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[0].height, 72) XCTAssertLessThanOrEqual(fixture.view.debugCardSizes[2].height, 72) @@ -430,7 +456,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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) + XCTAssertEqual(fixture.view.debugCardExpansionAnimationDuration, 0.22, accuracy: 0.01) XCTAssertTrue(fixture.view.debugFocusCard(at: 1)) drainMainQueue() @@ -459,7 +485,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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) + XCTAssertGreaterThan(fixture.view.debugCardContentBackgroundAlphas[1], 0.5) fixture.view.debugHoverCard(at: 1) RunLoop.main.run(until: Date().addingTimeInterval(0.06)) @@ -483,7 +509,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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(fixture.view.debugCardContentBackgroundAlphas[1], 0.5) XCTAssertGreaterThan(detailAfter.height, detailBefore.height + 120) XCTAssertTrue( abs(detailAfter.maxY - headerAfter.minY) <= 1 @@ -526,6 +552,7 @@ final class ClipboardPanelViewTests: XCTestCase { } drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() + let selectedIndexBeforeHover = fixture.viewModel.selectedIndex fixture.view.debugHoverCard(at: 1) RunLoop.main.run(until: Date().addingTimeInterval(fixture.view.debugCardExpansionAnimationDuration + 0.04)) @@ -543,7 +570,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) - XCTAssertEqual(fixture.viewModel.selectedIndex, 2) + XCTAssertEqual(fixture.viewModel.selectedIndex, selectedIndexBeforeHover) } func testToolbarControlsExposeVoiceOverActionHints() { @@ -551,11 +578,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugSearchFieldAccessibilityHelp, - "Type to search clipboard history. Use the side category icons to filter results." - ) - XCTAssertEqual( - fixture.view.debugSearchFilterButtonAccessibilityHelp, - "" + "Type to search clipboard history. Use the category icons beside the cards to filter results; Command-click combines categories." ) XCTAssertEqual( fixture.view.debugAddCollectionButtonAccessibilityHelp, @@ -573,10 +596,6 @@ final class ClipboardPanelViewTests: XCTestCase { ] ) - fixture.view.debugToggleCompactMode() - drainMainQueue() - - XCTAssertEqual(fixture.view.debugCompactModeButtonAccessibilityValue, "") XCTAssertEqual( fixture.view.debugToolbarButtonAccessibilityHelps, [ @@ -586,255 +605,7 @@ final class ClipboardPanelViewTests: XCTestCase { ) } - 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() { + func testActiveVerticalCardUsesSubtleDepthWithoutTranslation() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Neighbor card chrome", store: fixture.store)) fixture.store.upsert(makeTextItem("Active card chrome", store: fixture.store)) @@ -848,8 +619,9 @@ final class ClipboardPanelViewTests: XCTestCase { 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) + XCTAssertGreaterThan(fixture.view.debugFirstCardShadowOpacity, 0) + XCTAssertLessThanOrEqual(fixture.view.debugFirstCardShadowOpacity, 0.12) + XCTAssertLessThanOrEqual(fixture.view.debugFirstCardShadowRadius, 12) XCTAssertEqual(fixture.view.debugFirstCardLayerTranslationY, 0, accuracy: 0.5) XCTAssertEqual(fixture.view.debugFirstCardSlotTopOffset, 0, accuracy: 0.5) XCTAssertEqual(fixture.view.debugSecondCardSlotTopOffset, 0, accuracy: 0.5) @@ -866,7 +638,6 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id]) XCTAssertEqual(fixture.view.debugVisibleCardCount, 1) - XCTAssertEqual(fixture.view.debugResultCountText, "1 clip") XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack) XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292) XCTAssertGreaterThanOrEqual( @@ -914,7 +685,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugVisibleCardCount, 2) XCTAssertEqual(fixture.view.debugCardSizes.count, 2) XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-row", "vertical-row"]) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 280) + XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 240) XCTAssertLessThanOrEqual( fixture.view.debugCardSizes.first?.width ?? 0, fixture.view.debugCardRailVisibleRect.width @@ -932,7 +703,7 @@ final class ClipboardPanelViewTests: XCTestCase { ) } - func testCompactRowsShowCharacterMetricWithoutEllipsisOrOverflow() throws { + func testCompactRowsPrioritizeReadableTitleOverVerboseCharacterMetric() { let fixture = makePanelFixture() fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) fixture.store.upsert(makeTextItem(String(repeating: "a", count: 1_234), store: fixture.store)) @@ -942,13 +713,11 @@ final class ClipboardPanelViewTests: XCTestCase { 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) + XCTAssertEqual(fixture.view.debugCardCompactMetricTexts, ["", ""]) + XCTAssertEqual(fixture.view.debugCardCompactMetricHiddenStates, [true, true]) } - func testCompactRowsShowFileSizeMetricWhenBackingFileExists() throws { + func testCompactRowsKeepFileMetadataOutOfThePrimaryTitleLine() throws { let fixture = makePanelFixture() fixture.window.setFrame(NSRect(x: 0, y: 0, width: 336, height: 520), display: true) let fileURL = makeTempDirectory().appendingPathComponent("payload.bin") @@ -960,34 +729,8 @@ final class ClipboardPanelViewTests: XCTestCase { 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) + XCTAssertEqual(fixture.view.debugCardCompactMetricTexts, ["", ""]) + XCTAssertEqual(fixture.view.debugCardCompactMetricHiddenStates, [true, true]) } func testTallSideShelfUsesPasteStyleFocusedCard() { @@ -1016,7 +759,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugPanelLayout, ClipboardPanelLayout.vertical.title) + XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") XCTAssertEqual(fixture.view.debugItemsStackOrientation, .vertical) XCTAssertTrue(fixture.view.debugResizeHandleIsHidden) XCTAssertEqual(fixture.view.debugCardDensity, "compact") @@ -1024,7 +767,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertLessThanOrEqual(fixture.view.debugDocumentViewFrame.width, fixture.view.debugCardRailVisibleRect.width + 1) } - func testVerticalShelfSwitchesFromWidePanelWithoutKeepingWideCategoryRail() { + func testVerticalShelfUsesSideCategoryRailAndFullHeightCards() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Vertical switch note", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/vertical", store: fixture.store)) @@ -1036,10 +779,15 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugPanelLayout, ClipboardPanelLayout.vertical.title) + XCTAssertEqual(fixture.view.debugPanelLayout, "Side Shelf") XCTAssertEqual(fixture.view.bounds.width, 336, accuracy: 1) XCTAssertEqual(fixture.view.debugCollectionRailVisibleWidth, 36, accuracy: 0.5) - XCTAssertLessThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 280) + XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 300) + XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 240) + XCTAssertLessThanOrEqual( + fixture.view.debugCollectionRailFrameInPanel.maxX + 8, + fixture.view.debugSelectedCardFrameInPanel.minX + 0.5 + ) } func testCardsShowQuickPasteNumberBadgesForFirstNineItems() { @@ -1054,16 +802,6 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugQuickPasteBadgeTexts, ["1", "2", "3", "4", "5", "6", "7", "8", "9"]) } - func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() { - let fixture = makePanelFixture() - fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store)) - drainMainQueue() - - XCTAssertEqual(fixture.view.debugStatusText, "Capture running") - XCTAssertEqual(fixture.view.debugStatusTone, "ready") - XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste")) - } - func testCardFooterHidesMissingSourceInsteadOfShowingUnknown() { let fixture = makePanelFixture() var item = makeTextItem("No source noise", store: fixture.store) @@ -1128,34 +866,16 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.updateSelectedText(to: "Edited footer item") drainMainQueue() - XCTAssertEqual(fixture.view.debugStatusText, "Updated text clip") - XCTAssertEqual(fixture.view.debugStatusTone, "action") + XCTAssertEqual(fixture.viewModel.statusMessage, "Updated text clip") } - func testSkippedCaptureStatusUsesWarningTone() { - let fixture = makePanelFixture() - - fixture.settings.setCaptureStatus(message: "Skipped: Audio items are ignored in capture settings.") - drainMainQueue() - - XCTAssertEqual(fixture.view.debugStatusText, "Skipped: Audio items are ignored in capture settings.") - XCTAssertEqual(fixture.view.debugStatusTone, "warning") - } - - func testValidationStatusMessagesUseWarningTone() { + func testValidationActionsPublishUsefulStatusMessages() { 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") + XCTAssertEqual(fixture.viewModel.statusMessage, "No visible clips to stack") fixture.store.upsert(makeTextItem("Stack warning note", store: fixture.store)) drainMainQueue() @@ -1164,14 +884,12 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.addSelectedItemsToStack() drainMainQueue() - XCTAssertEqual(fixture.view.debugStatusText, "Added 1 selected clip to Stack") - XCTAssertEqual(fixture.view.debugStatusTone, "action") + XCTAssertEqual(fixture.viewModel.statusMessage, "Added 1 selected clip to Stack") fixture.viewModel.addSelectedItemsToStack() drainMainQueue() - XCTAssertEqual(fixture.view.debugStatusText, "Selected clips are already in Stack") - XCTAssertEqual(fixture.view.debugStatusTone, "warning") + XCTAssertEqual(fixture.viewModel.statusMessage, "Selected clips are already in Stack") } func testPanelShellUsesTransparentLiquidGlassSurface() { @@ -1180,7 +898,8 @@ final class ClipboardPanelViewTests: XCTestCase { view.layoutSubtreeIfNeeded() XCTAssertEqual(view.debugPanelMaterial, .hudWindow) - XCTAssertEqual(view.debugPanelCornerRadius, 22) + XCTAssertEqual(view.debugPanelCornerRadius, 14) + XCTAssertEqual(view.debugPanelShadowOpacity, 0) XCTAssertGreaterThan(view.debugPanelSurfaceAlpha, 0.10) XCTAssertLessThan(view.debugPanelSurfaceAlpha, 0.25) XCTAssertGreaterThan(view.debugPanelBorderAlpha, 0.30) @@ -1228,6 +947,9 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.store.upsert(item) } fixture.viewModel.selectItem(at: 0) + if ProcessInfo.processInfo.environment["CLIPBORED_VISUAL_SNAPSHOT_EXPAND_SEARCH"] == "1" { + fixture.view.debugExpandSearchPresentationForSnapshot() + } drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() fixture.view.displayIfNeeded() @@ -1252,7 +974,6 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugVisibleCardCount, 1) - XCTAssertEqual(fixture.view.debugResultCountText, "2 clips") fixture.view.finishOpeningTransition() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -1289,7 +1010,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Keep keyboard focus") } - func testHoveringAnotherCardBecomesLatestSingleSelectionForArrowNavigation() { + func testHoverExpansionDoesNotStealKeyboardSelectionOrNavigation() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Oldest hover note", store: fixture.store)) fixture.store.upsert(makeTextItem("Middle hover note", store: fixture.store)) @@ -1302,24 +1023,20 @@ final class ClipboardPanelViewTests: XCTestCase { 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.viewModel.selectedItem?.payload, "Newest hover note") + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) XCTAssertEqual(fixture.view.debugActiveCardIndexes, [2]) XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) XCTAssertEqual(fixture.view.debugCardPresentations.prefix(3), ["vertical-row", "vertical-row", "vertical-focus"]) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "mouse") + XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") XCTAssertEqual(fixture.view.debugCardVisibleActionLabels(at: 2), []) - fixture.view.debugPressFocusedResponderKeyCode(123) + fixture.view.debugPressFocusedResponderKeyCode(125) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -1346,14 +1063,14 @@ final class ClipboardPanelViewTests: XCTestCase { 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.viewModel.selectedItem?.payload, "Middle hover note") + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) XCTAssertEqual(fixture.view.debugHoveredCardIndexes, [2]) - XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "mouse") + XCTAssertEqual(fixture.view.debugCardSelectionInputSource, "keyboard") } - func testCollectionRailUsesIconOnlySideItemsAndTracksSelection() { + func testCollectionRailUsesCompactSideIconsAndTracksSelection() { let fixture = makePanelFixture() drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -1373,7 +1090,6 @@ final class ClipboardPanelViewTests: XCTestCase { 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() @@ -1408,6 +1124,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() + fixture.view.debugHoverCollectionChip(.text) fixture.view.debugCommandMouseDownCollectionChip(.text) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -1415,6 +1132,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Text category note"]) + fixture.view.debugHoverCollectionChip(.links) fixture.view.debugCommandMouseDownCollectionChip(.links) drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() @@ -1423,6 +1141,63 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/category", "Text category note"]) } + func testSelectingCategoryLeavesOnlyTheNewCategoryVisuallyHighlighted() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) + fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) + XCTAssertGreaterThan(fixture.view.debugCollectionChipBackgroundAlpha(.mostRecent), 0.4) + + fixture.view.debugMouseDownCollectionChip(.text) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) + XCTAssertLessThanOrEqual(fixture.view.debugCollectionChipBackgroundAlpha(.mostRecent), 0.1) + XCTAssertGreaterThan(fixture.view.debugCollectionChipBackgroundAlpha(.text), 0.4) + } + + func testOpeningShortcutModifiersDoNotAccidentallyCombineCategorySelection() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) + fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + fixture.view.debugMouseDownCollectionChip(.mostUsed) + fixture.view.debugMouseDownCollectionChip(.text, modifiers: [.command, .shift]) + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text"]) + + fixture.view.debugMouseDownCollectionChip(.links, modifiers: .command) + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text", "Links"]) + } + + func testCommandSpaceCombinesFocusedCategoryChips() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Text category note", store: fixture.store)) + fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/category", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.text)) + fixture.view.debugPressFocusedResponderKeyCode(49, modifiers: .command) + drainMainQueue() + + XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links)) + fixture.view.debugPressFocusedResponderKeyCode(49, modifiers: .command) + drainMainQueue() + + XCTAssertEqual(fixture.view.debugSelectedSortCollectionTitles, ["Text", "Links"]) + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/category", "Text category note"]) + } + func testCollectionRailChipsAreKeyboardFocusableAndVoiceOverDescriptive() { let fixture = makePanelFixture() fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) @@ -1435,15 +1210,14 @@ final class ClipboardPanelViewTests: XCTestCase { ) XCTAssertEqual( fixture.view.debugCollectionCountLabelHiddenStates, - Array(repeating: true, count: fixture.view.debugCollectionTitles.count) + [true, true, true] ) 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." + "Press Return or Space to show Clipboard; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories." ) - XCTAssertEqual(fixture.view.debugCategoryMenuTitles, []) XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostUsed)) fixture.view.debugPressFocusedResponderWithSpace() drainMainQueue() @@ -1452,7 +1226,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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." + "Press Return or Space to show Frequent; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories." ) ) @@ -1462,7 +1236,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") } - func testCollectionRailChipArrowsMoveCardSelectionInsteadOfChangingCategories() { + func testCollectionRailUsesHorizontalArrowsForChipsAndVerticalArrowsForCards() { let fixture = makePanelFixture() var clientItem = makeTextItem("Client collection item", store: fixture.store) clientItem.collectionName = "Client Work" @@ -1484,22 +1258,26 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.view.debugPressFocusedResponderKeyCode(124) drainMainQueue() XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") - XCTAssertEqual(fixture.viewModel.selectedIndex, 1) - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, []) + XCTAssertEqual(fixture.viewModel.selectedIndex, 0) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Frequent"]) fixture.view.debugPressFocusedResponderKeyCode(123) drainMainQueue() XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") XCTAssertEqual(fixture.viewModel.selectedIndex, 0) - XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) - XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Clipboard"]) + + fixture.view.debugPressFocusedResponderKeyCode(125) + drainMainQueue() + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") + XCTAssertEqual(fixture.viewModel.selectedIndex, 1) + XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [1]) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) XCTAssertTrue(fixture.view.debugFocusCollectionChip(.mostRecent)) fixture.view.debugPressFocusedResponderKeyCode(119) drainMainQueue() - XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") XCTAssertEqual(fixture.view.debugKeyboardFocusedCollectionTitles, ["Stack"]) fixture.view.debugPressFocusedResponderKeyCode(115) @@ -1616,13 +1394,13 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Older than a day"]) - XCTAssertEqual(fixture.view.debugStatusText, "Cleared 1 clip") + XCTAssertEqual(fixture.viewModel.statusMessage, "Cleared 1 clip") fixture.view.debugPerformClearHistoryMenuItem(titled: "All Time") drainMainQueue() XCTAssertTrue(fixture.viewModel.visibleItems.isEmpty) - XCTAssertEqual(fixture.view.debugStatusText, "Cleared 1 clip") + XCTAssertEqual(fixture.viewModel.statusMessage, "Cleared 1 clip") } func testCollectionFilteredCardsUseStoredCollectionHeaderColor() { @@ -1635,13 +1413,26 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"]) - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack") - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now") + XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Collect this note") + XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • Just now") XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#0A9EB8") XCTAssertEqual(fixture.view.debugCustomCollectionColorHexes["Research Stack"], "#0A9EB8") XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters") } + func testCompactCardsUseFullColorHeaderSurface() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Full color card", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual( + fixture.view.debugFirstCardHeaderSurfaceColorHex, + fixture.view.debugFirstCardHeaderColorHex + ) + XCTAssertEqual(fixture.view.debugFirstCardHeaderSurfaceAlpha, 0.96, accuracy: 0.01) + } + func testCardHeaderUsesPasteStyleRelativeAgeText() { let fixture = makePanelFixture() var item = makeTextItem("Readable age", store: fixture.store) @@ -1651,8 +1442,8 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text") - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "3 minutes ago") + XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Readable age") + XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • 3 minutes ago") fixture.viewModel.createCollection(named: "Age Stack", colorHex: "#FF3B30", selectAfterCreate: false) fixture.viewModel.assignSelected(to: "Age Stack") @@ -1660,7 +1451,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - 3 minutes ago") + XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text • Ghostty • 3 minutes ago") } func testCollectionChipsExposeManagementMenuActions() { @@ -1675,7 +1466,7 @@ final class ClipboardPanelViewTests: XCTestCase { ) 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." + "Press Return or Space to show Research Stack; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories. Open the context menu to edit, export, or delete this Pinboard." ) } @@ -1698,7 +1489,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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.debugFirstCardHeaderTitle, "Collect this note") XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#3366FF") fixture.view.debugDeleteCollection(named: "Product Research") @@ -1822,7 +1613,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 0) XCTAssertEqual(fixture.view.debugCardPresentations, ["vertical-focus"]) XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeIsHidden) - XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5) + XCTAssertGreaterThanOrEqual(fixture.view.debugCardSizes.first?.width ?? 0, 520) XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 220, accuracy: 0.5) XCTAssertEqual( fixture.view.debugFirstCardMenuTitles, @@ -1923,8 +1714,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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.viewModel.statusMessage, "Deleted 2 clips") XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0]) XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) @@ -1937,8 +1727,7 @@ final class ClipboardPanelViewTests: XCTestCase { ["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.viewModel.statusMessage, "Restored 2 clips") XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 2]) } @@ -2144,6 +1933,11 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.view.debugPressFocusedResponderKeyCode(124) drainMainQueue() + XCTAssertEqual(fixture.viewModel.selectedIndex, 0) + XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) + + fixture.view.debugPressFocusedResponderKeyCode(125) + drainMainQueue() XCTAssertEqual(fixture.viewModel.selectedIndex, 1) XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1]) @@ -2167,12 +1961,12 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.selectedIndex, 0) XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - fixture.view.debugPressFocusedResponderKeyCode(123) + fixture.view.debugPressFocusedResponderKeyCode(126) drainMainQueue() XCTAssertEqual(fixture.viewModel.selectedIndex, 0) XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [0]) - fixture.view.debugPressFocusedResponderKeyCode(124, modifiers: .shift) + fixture.view.debugPressFocusedResponderKeyCode(125, modifiers: .shift) drainMainQueue() XCTAssertEqual(fixture.viewModel.selectedIndex, 1) XCTAssertEqual(fixture.view.debugSelectedCardIndexes, [0, 1]) @@ -2181,7 +1975,6 @@ final class ClipboardPanelViewTests: XCTestCase { 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]) } @@ -2205,7 +1998,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertFalse(fixture.view.debugFirstCardHeaderBadgeMaskedCorners.contains(.layerMaxXMinYCorner)) XCTAssertGreaterThanOrEqual( fixture.view.debugFirstCardHeaderBadgeContentFrame.width, - fixture.view.debugFirstCardHeaderBadgeFrame.width * 0.70 + fixture.view.debugFirstCardHeaderBadgeFrame.width * 0.55 ) } @@ -2273,7 +2066,6 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(ClipboardSortMode.allCases.map { countSummary.count(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) XCTAssertEqual(fixture.view.debugCollectionCounts, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1]) XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: true, count: 11)) - XCTAssertEqual(fixture.view.debugCategoryMenuTitles, []) } func testCollectionRailShowsAssignedCollections() { @@ -2355,26 +2147,22 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertFalse(fixture.view.debugCollectionRailHasHorizontalScroller) XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 300) XCTAssertGreaterThan( - fixture.view.debugCollectionRailVisibleRect.height, - 1 + fixture.view.debugCollectionRailContentFrameInPanel.height, + fixture.view.debugCollectionRailVisibleRect.height ) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work")) XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References")) XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeWidth, 44, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) fixture.view.debugScrollCollectionRailVertically(deltaY: -220) fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertGreaterThanOrEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) + XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleRect.minY, 0) fixture.view.debugScrollCollectionRailVertically(deltaY: 10_000) fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugCollectionRailVisibleRect.minY, 0, accuracy: 0.5) - XCTAssertEqual(fixture.view.debugCollectionRailOverflowFadeVisibility, [false, false]) } func testSelectionScrollsCardRailToKeepSelectedCardVisible() { @@ -2475,9 +2263,6 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { @@ -2516,7 +2301,7 @@ final class ClipboardPanelViewTests: XCTestCase { ) } - func testCaptureRuleActionsUseWarningStatusTone() { + func testCaptureRuleActionsPublishUsefulStatusMessages() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Capture rule status tone", store: fixture.store)) drainMainQueue() @@ -2526,15 +2311,13 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() XCTAssertTrue(fixture.settings.ignoredApps.contains("Ghostty")) - XCTAssertEqual(fixture.view.debugStatusText, "Ignored Ghostty for future captures") - XCTAssertEqual(fixture.view.debugStatusTone, "warning") + XCTAssertEqual(fixture.viewModel.statusMessage, "Ignored Ghostty for future captures") fixture.view.debugPerformFirstCardCaptureRuleMenuItem(titled: "Ignore Text Items") drainMainQueue() XCTAssertTrue(fixture.settings.ignoredItemKindsRaw.contains(ClipboardItemKind.text.rawValue)) - XCTAssertEqual(fixture.view.debugStatusText, "Ignored Text items for future captures") - XCTAssertEqual(fixture.view.debugStatusTone, "warning") + XCTAssertEqual(fixture.viewModel.statusMessage, "Ignored Text items for future captures") } func testFilteredCardsExposeShowInClipboardContextMenuAction() { @@ -2586,7 +2369,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Release needle"]) XCTAssertEqual(fixture.view.debugSearchFieldText, "release") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) drainMainQueue() @@ -2615,7 +2398,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugSearchFieldText, "launch") - XCTAssertEqual(fixture.view.debugSearchFieldWidth, 164, accuracy: 0.5) + XCTAssertEqual(fixture.view.debugSearchFieldWidth, 240, accuracy: 0.5) XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Launch note"]) XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) @@ -2686,7 +2469,6 @@ final class ClipboardPanelViewTests: XCTestCase { 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")) @@ -2697,7 +2479,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugStatusText, "Added 2 selected clips to Stack") + XCTAssertEqual(fixture.viewModel.statusMessage, "Added 2 selected clips to Stack") XCTAssertEqual(fixture.view.debugStackChipCount, 2) } @@ -2719,10 +2501,9 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertTrue(fixture.view.debugFocusCard(at: 0)) drainMainQueue() XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, []) - XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"]) } - func testStackCornerButtonTogglesAndPersistsForQueuedCards() { + func testStackMembershipUpdatesCardsWithoutRebuildingThem() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("Older stack item", store: fixture.store)) fixture.store.upsert(makeTextItem("Newest stack item", store: fixture.store)) @@ -2731,8 +2512,6 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Add to Stack", "Add to Stack"]) - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) XCTAssertFalse(fixture.view.debugFirstCardVisibleActionLabels.contains("Add to Stack")) let cardIdentifiersBeforeStackToggle = fixture.view.debugCardObjectIdentifiers @@ -2740,17 +2519,13 @@ final class ClipboardPanelViewTests: XCTestCase { 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.viewModel.statusMessage, "Added to Stack") XCTAssertEqual(fixture.view.debugCardObjectIdentifiers, cardIdentifiersBeforeStackToggle) - XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack", "Add to Stack"]) - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) + XCTAssertTrue(fixture.view.debugFirstCardMenuTitles.contains("Remove from Stack")) XCTAssertTrue(fixture.view.debugStackChipIsVisible) XCTAssertEqual(fixture.view.debugStackChipCount, 1) @@ -2758,7 +2533,7 @@ final class ClipboardPanelViewTests: XCTestCase { drainMainQueue() fixture.window.contentView?.layoutSubtreeIfNeeded() - XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [true, true]) + XCTAssertEqual(fixture.view.debugCardObjectIdentifiers, cardIdentifiersBeforeStackToggle) } func testStackChipAppearsFiltersAndClearsWithStack() { @@ -2813,8 +2588,7 @@ final class ClipboardPanelViewTests: XCTestCase { 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") + XCTAssertEqual(fixture.viewModel.statusMessage, "Stack capture is on") fixture.view.debugPressStackChip() drainMainQueue() @@ -2831,8 +2605,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertFalse(fixture.viewModel.isStackCaptureEnabled) XCTAssertFalse(fixture.view.debugStackChipIsVisible) - XCTAssertEqual(fixture.view.debugStatusText, "Stack capture is off") - XCTAssertEqual(fixture.view.debugStatusTone, "action") + XCTAssertEqual(fixture.viewModel.statusMessage, "Stack capture is off") } func testStackChipMenuAddsVisibleShelfToQueue() { @@ -2865,7 +2638,7 @@ final class ClipboardPanelViewTests: XCTestCase { ) 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." + "Press Return or Space to show Stack; Command-Return or Command-Space combines categories. Use Left/Right to move between categories, Up/Down to move between clips, and Home/End to jump between categories. Open the context menu for Stack capture and Stack paste actions." ) fixture.view.debugAddVisibleClipsToStackFromStackChip() @@ -2873,7 +2646,7 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.window.contentView?.layoutSubtreeIfNeeded() XCTAssertEqual(fixture.view.debugStackChipCount, 3) - XCTAssertEqual(fixture.view.debugStatusText, "Added 2 clips to Stack") + XCTAssertEqual(fixture.viewModel.statusMessage, "Added 2 clips to Stack") fixture.view.debugPressStackChip() drainMainQueue() @@ -3119,8 +2892,7 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Client Launch Brief"]) XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Client Launch Brief|~/Documents|PDF"]) - XCTAssertEqual(fixture.view.debugStatusText, "Renamed clip") - XCTAssertEqual(fixture.view.debugStatusTone, "action") + XCTAssertEqual(fixture.viewModel.statusMessage, "Renamed clip") fixture.viewModel.searchText = "launch" drainMainQueue() @@ -3420,54 +3192,16 @@ final class ClipboardPanelViewTests: XCTestCase { 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 - ) + XCTAssertGreaterThan(fixture.view.debugCollectionRailHeight, 200, file: file, line: line) XCTAssertEqual(fixture.view.debugCollectionStackOrientation, .vertical, file: file, line: line) - XCTAssertLessThan( - fixture.view.debugCollectionRailFrameInPanel.midX, - fixture.view.bounds.midX, + XCTAssertLessThanOrEqual( + fixture.view.debugCollectionRailFrameInPanel.maxX + 8, + fixture.view.debugSelectedCardFrameInPanel.minX + 0.5, 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) { let fixture = makePanelFixture() return (fixture.window, fixture.view) diff --git a/tests/clipboredtests/SettingsModelTests.swift b/tests/clipboredtests/SettingsModelTests.swift index 78a75c1..ce3811b 100644 --- a/tests/clipboredtests/SettingsModelTests.swift +++ b/tests/clipboredtests/SettingsModelTests.swift @@ -84,27 +84,6 @@ final class SettingsModelTests: XCTestCase { 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)! @@ -115,17 +94,14 @@ final class SettingsModelTests: XCTestCase { 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]) + XCTAssertEqual(changes, [.panelSide]) let restored = SettingsModel(defaults: defaults) - XCTAssertEqual(restored.panelLayout, .vertical) XCTAssertEqual(restored.panelSide, .left) } @@ -178,27 +154,6 @@ final class SettingsModelTests: XCTestCase { 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)! diff --git a/tests/clipboredtests/SettingsWindowControllerTests.swift b/tests/clipboredtests/SettingsWindowControllerTests.swift index 6923889..040e63d 100644 --- a/tests/clipboredtests/SettingsWindowControllerTests.swift +++ b/tests/clipboredtests/SettingsWindowControllerTests.swift @@ -20,10 +20,10 @@ final class SettingsWindowControllerTests: XCTestCase { 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) + XCTAssertEqual(controller.debugWindowMinSize.width, 560, accuracy: 0.5) + XCTAssertEqual(controller.debugWindowMinSize.height, 440, accuracy: 0.5) + XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.width, 600) + XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.height, 500) } func testSettingsTabsUsePinnedScrollableDocuments() { @@ -40,12 +40,12 @@ final class SettingsWindowControllerTests: XCTestCase { } } - func testSettingsDataTabKeepsExtraNativeHighlightPadding() { + func testSettingsTabsUseUnpaddedLabelsWithCustomSelector() { 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) + XCTAssertEqual(controller.debugRawSettingsTabLabels.last, "Data") } func testSettingsTabsKeepContentAnchoredToTopLeadingCorner() { @@ -64,7 +64,7 @@ final class SettingsWindowControllerTests: XCTestCase { func testSettingsTabsAvoidHorizontalOverflowAtMinimumWindowSize() { let (controller, _) = makeController() - controller.debugSetWindowContentSize(NSSize(width: 620, height: 560)) + controller.debugSetWindowContentSize(NSSize(width: 560, height: 440)) let metrics = controller.debugSettingsTabLayoutAuditMetrics @@ -75,6 +75,63 @@ final class SettingsWindowControllerTests: XCTestCase { } } + func testSettingsGeneralTabUsesCompactVerticalLayout() { + let (controller, _) = makeController() + let metrics = controller.debugSettingsTabContentPlacementMetrics + let general = try! XCTUnwrap(metrics.first { $0.label == "General" }) + + XCTAssertLessThan(general.contentBounds.maxY, 380) + } + + func testShortcutModifiersExposeHumanAccessibilityNames() { + let (controller, _) = makeController() + + XCTAssertEqual( + controller.debugOpenShortcutModifierAccessibilityLabels, + ["Command modifier", "Option modifier", "Control modifier", "Shift modifier"] + ) + XCTAssertEqual( + controller.debugOpenShortcutModifierAccessibilityHelps, + [ + "Include or remove the Command key from this shortcut.", + "Include or remove the Option key from this shortcut.", + "Include or remove the Control key from this shortcut.", + "Include or remove the Shift key from this shortcut." + ] + ) + } + + func testWritesSettingsVisualSnapshotWhenRequested() throws { + guard ProcessInfo.processInfo.environment["CLIPBORED_WRITE_SETTINGS_SNAPSHOT"] == "1" else { + throw XCTSkip("Set CLIPBORED_WRITE_SETTINGS_SNAPSHOT=1 to write a Settings snapshot.") + } + + let (controller, _) = makeController() + controller.debugSelectSettingsTab(at: 5) + controller.debugPrepareWindowForSnapshot() + drainMainQueue() + let view = try XCTUnwrap(controller.debugSettingsContentView) + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + + let output = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["CLIPBORED_SETTINGS_SNAPSHOT_PATH"] + ?? "build/visual-snapshots/settings-data.png" + ) + try FileManager.default.createDirectory( + at: output.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + rep.size = view.bounds.size + view.cacheDisplay(in: view.bounds, to: rep) + let image = NSImage(size: view.bounds.size) + image.addRepresentation(rep) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation))) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: output) + } + func testCloudSyncStatusIsCachedAcrossUnrelatedSettingsRefreshes() { let cloudSyncService = CloudSyncProbe( status: ClipboardCloudSyncStatus( diff --git a/tests/clipboredtests/ShortcutManagerTests.swift b/tests/clipboredtests/ShortcutManagerTests.swift index 7ee391a..bd0c7bc 100644 --- a/tests/clipboredtests/ShortcutManagerTests.swift +++ b/tests/clipboredtests/ShortcutManagerTests.swift @@ -36,24 +36,11 @@ final class ShortcutManagerTests: XCTestCase { manager.stop() } - func testRejectsUnsupportedSettingsShortcutBeforeRegistration() { - let manager = makeManager( - openShortcut: AppConfiguration.defaultOpenShortcut, - settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue) - ) + func testGlobalRegistrationExcludesLocalSettingsShortcut() { + let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut) - XCTAssertEqual(manager.start(), .unsupportedShortcut("⌘SPACE")) - manager.stop() - } - - func testRejectsDuplicateShortcutBindingsBeforeRegistration() { - let manager = makeManager( - openShortcut: AppConfiguration.defaultOpenShortcut, - settingsShortcut: AppConfiguration.defaultOpenShortcut - ) - - XCTAssertEqual(manager.start(), .conflict(AppConfiguration.defaultOpenShortcut.displayText)) - manager.stop() + XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut]) + XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut)) } func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() { @@ -63,15 +50,10 @@ final class ShortcutManagerTests: XCTestCase { manager.stop() } - private func makeManager( - openShortcut: ShortcutBinding, - settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut - ) -> ShortcutManager { + private func makeManager(openShortcut: ShortcutBinding) -> ShortcutManager { ShortcutManager( onOpenClipboardPanel: {}, - onOpenSettings: {}, - openShortcut: openShortcut, - settingsShortcut: settingsShortcut + openShortcut: openShortcut ) } }