Skip to content

Fix popup rendering, bounds, pinned-paste crash, and search swallow#1378

Open
p3ngu1nx wants to merge 7 commits into
p0deje:masterfrom
p3ngu1nx:fix/preview-performance
Open

Fix popup rendering, bounds, pinned-paste crash, and search swallow#1378
p3ngu1nx wants to merge 7 commits into
p0deje:masterfrom
p3ngu1nx:fix/preview-performance

Conversation

@p3ngu1nx
Copy link
Copy Markdown

@p3ngu1nx p3ngu1nx commented Apr 14, 2026

Summary

Fixes the "Not Responding" freeze that occurs when hovering over clipboard items with the preview panel, a separate list-scrolling freeze on rows that contain large image thumbnails, two positioning bugs where the popup could extend beyond the visible screen, a crash when re-pasting a pinned clipboard item while the unpinned history is at capacity, and a regression where the search field would swallow keystrokes matching the popup hotkey's letter.

The hover freeze is reproducible by scrolling through the history list and rapidly moving the mouse up and down across items — the main thread blocks hard enough that macOS's watchdog will eventually kill the process. The list-scrolling freeze reproduces independently by scrolling to a clipboard row that holds a large composite screenshot — the row's first appearance pegs the main thread at 100% CPU for 12+ seconds. The pinned-paste crash reproduces by filling the unpinned history to Defaults[.size] and then re-pasting a pinned item — History.add traps in Array.insert(_:at:) with "index out of range" (three production .ips reports captured this signature on May 2-3). The search-swallow bug reproduces by opening the popup with the default ⌘⇧V hotkey, releasing modifiers, and typing "v" in the search field — the keystroke moves the selection down instead of inserting "v" into the query, making it impossible to search for any term containing the letter V.

Root causes

Six separate main-thread / event-handling issues:

  1. hasImage allocated a full NSImage just to check existence. item.image != nil decodes the entire image blob; item.imageData != nil is a cheap content-type lookup.

  2. Image decode and resize ran on @MainActor. The Task { } wrapper inherited the actor context from its @MainActor caller, so NSImage(data:) and .resized(to:) still executed on the main thread. Replaced with Task.detached + raw Data capture.

  3. NSAttributedString(html:) parsed synchronously in the view body. HTML clipboard content (from browsers) invokes WebKit internally, which blocks rendering for hundreds of milliseconds. Moved behind an async AsyncView operation.

  4. LazyVStack row-layout cycling on rows with image thumbnails. Separate from the hover path. The history row rendered Image(nsImage:) without an explicit frame and switched between image / accessoryImage / title-fallback branches depending on whether the async thumbnail had completed. The view-tree shape changed when thumbnails finished loading, forcing LazyVStack to re-measure and invalidating its row-height cache for everything below. Compounding this, NSImage+Resized.resized(to:) returned an NSImage(size:flipped:drawingHandler:) whose intrinsic size varied subtly per redraw, defeating the lazy cache. The combined effect was a runaway SwiftUI transaction loop — captured by macOS's CPU resource diagnostic showing time spent in LazySubviewPlacements.placeSubviewsLazyStack.placeLazyLayoutViewCache.commitPlacedSubviewsArray.sortForDisplayLarge, with no time in image decoding (decoding is already off-thread after fix Cannot select with arrow keys after using mouse #2).

  5. History.add trapped on stale index after limitHistorySize mutation. When a pinned clipboard item was re-pasted, the function captured removedItemIndex for the existing pinned row, removed it, then called limitHistorySize which can mutate all further by deleting unpinned rows. The captured index then becomes stale, and the subsequent Array.insert(_:at:) traps with "index out of range" if the new array length is smaller than the captured index. Hits when the unpinned history is at capacity.

  6. Popup.handleKeyDown matched the popup hotkey by keyCode only. The local NSEvent monitor's gate if isHotKeyCode(Int(event.keyCode)) compared only event.keyCode against the popup shortcut's key, ignoring modifier flags. With the default ⌘⇧V popup hotkey, plain "v" with no modifiers still satisfied this check; combined with a keyDown-vs-flagsChanged event-ordering race that could leave state == .opening or state == .cycle, the keystroke routed to the cycle/highlightNext branch and return nil swallowed it before the search field's onKeyPress ever ran. Once stuck in .cycle (and modifiers were already empty so no further flagsChanged would fire), every subsequent "v" repeated the cycle behavior.

A seventh issue emerged during the preview fixes: with the preview open, AsyncView's .task { } fires only on appear, so navigating between items showed stale content. An initial attempt using .id(item.id) on PreviewItemView caused full view teardown/recreation on every hover — this itself froze the app under rapid mouse movement. The final fix adds a taskId parameter to AsyncView that uses .task(id:), which cancels and restarts the async operation on id change without destroying the view.

Changes

  • HistoryItemDecorator: hasImage uses imageData, image generation uses Task.detached, new asyncGetPreviewText() with lazy content reading and @ObservationIgnored cache.
  • HistoryItem: generateTitle() uses imageData == nil instead of image == nil.
  • AsyncView: Added id init parameter that switches to .task(id:) internally. Prior init without id is preserved for backwards compat.
  • PreviewItemView: Text branch wrapped in AsyncView; both image and text AsyncViews pass item.id.
  • FloatingPanel.open() and SlideoutController.togglePreview(): Clamp window origin to the visible screen frame on both axes so the popup/slideout can't extend off-screen.
  • ListItemView: image / accessoryImage branches wrapped in a single Group with .frame(maxWidth: 340, maxHeight: imageMaxHeight), so the row's view shape stays stable across async thumbnail completion.
  • NSImage+Resized.resized(to:): replaces the deferred-draw NSImage(size:flipped:drawingHandler:) with an eager NSBitmapImageRep bake at the highest active screen's backingScaleFactor (so cached bitmaps stay sharp on multi-display setups). Falls back to self if either the bitmap or graphics context can't be allocated.
  • History.add(_:): all.insert(itemDecorator, at: removedItemIndex) becomes all.insert(itemDecorator, at: min(removedItemIndex, all.count)). Array.insert(at:) accepts 0...count, so clamping to count is always valid; when the original slot was trimmed, the re-pinned item lands at the end of all instead of crashing.
  • Popup.handleKeyDown: adds a guard isHotKeyModifiers(event.modifierFlags) else { return event } after the pressedShortcutItem block so non-hotkey-letter keystrokes fall through to the search field. The redundant && isHotKeyModifiers(...) is dropped from the state == .toggle branch (the new guard already enforces it). The cycle-paste workflow (⌘⇧V held → highlightNext) is unchanged because both keyCode and modifiers match in that case.
  • HistoryDecoratorTests: Image tests made async to await the detached generation; added tests for hasImage and asyncGetPreviewText.
  • HistoryTests.testRepinningAtCapacityDoesNotCrash: regression test that fills unpinned history to capacity and re-adds a duplicate of a pinned item with pinTo = .bottom — reliably reproduced the crash on the pre-fix code path.

Verification

Tested by copying a mix of content types (plain text, HTML from browsers, large images, file URLs) and:

  • Hovering over individual items — preview loads without freeze.
  • Scrolling to mid-list, rapidly moving mouse up/down across items — no freeze, content updates smoothly.
  • Scrolling through history that contains large composite screenshots — no freeze; thumbnail dimensions match prior behavior.
  • Positioning the popup near all four edges of the screen — no clipping.
  • Dragging the popup between a Retina built-in display and a 1× external monitor with image rows visible — thumbnails stay crisp on both.
  • With Defaults[.size] = 3, pinning an item, filling the unpinned slots with three copies, and re-pasting the pinned item — pre-fix this crashed with "index out of range"; post-fix the pin survives and remains in the popup.
  • Opening the popup with ⌘⇧V, releasing the modifiers, and typing "valley" / "vivid" / "vvvvv" in the search field — pre-fix the V keystrokes were swallowed and the selection cycled down; post-fix the characters insert into the query and history filters correctly. Holding ⌘⇧V (the intended cycle gesture) still cycles the selection as before; pressing ⌘⇧V while the popup is in .toggle state still closes it.

Unit tests pass.

Notes for reviewers

  • Commits tell the debugging story (each issue surfaced after fixing the previous one). Happy to squash to a single commit if preferred.
  • SwiftData model access stays on the main actor — only raw Data bytes and value types cross into Task.detached.
  • Trade-off worth flagging on the new NSImage+Resized path: the bake target is colorSpaceName: .deviceRGB, which converts P3-tagged source screenshots to deviceRGB at thumbnail size. Visually imperceptible at ≤340pt but a real change vs. the prior deferred-draw path, which preserved the source colorspace until composite time.
  • .resizable().scaledToFit() is intentionally omitted on the SwiftUI Image in ListItemViewPopup.itemHeight = 24 would otherwise become the row floor and shrink the thumbnail into a 24pt cell. The eager bake gives the NSImage a correct intrinsic point size, and the explicit frame is the only sizing control needed.
  • Minor list-row behavior note: ListItemView previously rendered the accessoryImage and the main image as two stacked positions inside the row when both were non-nil. The Group { if let image … else if let accessoryImage … } wrapper picks one. In practice only one of the two is ever set on a given row, so this should be invisible in normal usage; calling it out for transparency.
  • On the search-swallow fix in Popup.handleKeyDown: the existing call ordering is preserved on purpose. pressedShortcutItem runs before the new modifier guard so list-item shortcuts (⌘1, ⌘2, …) still resolve through their own HistoryItemAction(modifierFlags) validation. The pre-existing requirement that pressedShortcutItem is only consulted when the keyCode equals the popup hotkey's keyCode is left as-is — behaviour-preserving and out of scope for a one-line bugfix.
  • The pinned-paste crash and the V-key search swallow are bundled with the rendering fixes because all four are surgical correctness fixes touching unrelated code paths in the popup surface, and a single PR keeps reviewer overhead low. Happy to split if preferred.
  • Pre-existing items not addressed in this PR (orthogonal, predate this change): NSAttributedString HTML/RTF parser input size limits, file URL canonicalization, Vision framework text recognition callback thread safety.

Boriza added 5 commits April 12, 2026 16:19
The app would freeze ("Not Responding" at 100% CPU) when hovering over
clipboard items because the preview panel triggered expensive synchronous
operations on the main thread:

- hasImage was creating a full NSImage just to check existence; now uses
  the cheap imageData lookup instead
- Image decode and resize (NSImage(data:) + .resized()) ran on @mainactor
  via inherited Task context; now uses Task.detached to run on background
  thread, assigning results back on MainActor
- HTML/RTF text preview parsed NSAttributedString synchronously in the
  view body; now wrapped in AsyncView with background Task.detached and
  caching
The previous fix (0e13280) moved heavy work off the main thread but
introduced two issues:

1. AsyncView used .task{} which fires once on appear — navigating
   between items showed stale preview content because the task never
   re-fired. The initial fix (.id(item.id) on PreviewItemView) caused
   full view teardown/recreation on every hover, which froze the app
   when the user moved the mouse rapidly across items.

   Fix: Added an optional id parameter to AsyncView that uses
   .task(id:) internally. This re-fires the async operation when the
   item changes WITHOUT destroying the view — just cancels the old
   task and starts a new one.

2. asyncGetPreviewText() read ALL content types (text, RTF, HTML,
   fileURLs) upfront on the main thread even when only one was needed.
   For browser-copied items with both text+HTML, this doubled the
   SwiftData reads unnecessarily.

   Fix: Lazy content reading following the priority chain — returns
   immediately for plain text (most common case), only falls through
   to Task.detached for RTF/HTML parsing. Also marked cachedPreviewText
   as @ObservationIgnored to prevent unnecessary view updates.
The popup window and its preview slideout could extend beyond the
screen edges — most visibly when the popup appeared near the bottom
or left edge of the screen:

- FloatingPanel.open(): added screen bounds clamping after computing
  the popup origin so the window never starts below, left of, or
  right of the visible screen area
- SlideoutController.togglePreview(): added screen bounds clamping
  after the left-shift for left-side slideout placement so the
  preview panel doesn't go off the left or bottom edge
- Complete Y-axis clamping in FloatingPanel.open() and
  SlideoutController.togglePreview() — also clamp the top edge so
  the window can't extend above visibleFrame.maxY when height is
  larger than the screen can accommodate below the origin.
- In asyncGetPreviewText(), skip caching an empty fileURL result so
  pathological clipboard items fall through to the slow path instead
  of being permanently cached as empty.
Rows containing image clipboard items could trigger a runaway SwiftUI
layout transaction loop, producing a 12s+ main-thread hang at 100% CPU
on rows holding large composite screenshots. A CPU resource diagnostic
captured the freeze in LazySubviewPlacements.placeSubviews ->
LazyStack.place -> LazyLayoutViewCache.commitPlacedSubviews ->
Array.sortForDisplayLarge, with no time in image decoding (decoding is
already off-thread).

Two contributing factors:

1. The history row rendered Image(nsImage:) without an explicit frame
   and switched between image / accessoryImage / title-fallback branches
   depending on whether the async thumbnail had completed. The view tree
   shape changed when thumbnails finished loading, which forced
   LazyVStack to re-measure the row and invalidate its row-height cache
   for everything below.

2. NSImage+Resized.resized(to:) returned an NSImage(size:flipped:
   drawingHandler:) whose contents were a deferred draw. The intrinsic
   size reported to SwiftUI varied subtly per redraw, defeating the
   lazy layout cache.

Fix:

* ListItemView wraps the image / accessoryImage branches in a single
  Group with an explicit frame capped at imageMaxHeight, so the row's
  view shape is stable across async thumbnail completion.

* NSImage+Resized.resized(to:) eagerly bakes an NSBitmapImageRep at the
  highest active screen's backingScaleFactor so the cached bitmap stays
  sharp on any display the popup is dragged to, and falls back to self
  if either the bitmap or the graphics context can't be allocated.

Trade-off: the bake target is colorSpaceName: .deviceRGB, which
converts P3-tagged source screenshots to deviceRGB at thumbnail size.
Visually imperceptible at <=340pt but a real change vs. the prior
deferred-draw path which preserved the source colorspace until
composite time.

.resizable().scaledToFit() is intentionally omitted on the SwiftUI
Image - Popup.itemHeight = 24 would otherwise become the row floor and
shrink the thumbnail into a 24pt cell. The eager bake gives the
NSImage a correct intrinsic point size, and the explicit frame is the
only sizing control needed.
@p3ngu1nx p3ngu1nx changed the title Fix preview rendering performance and screen bounds Fix popup rendering performance and screen bounds May 1, 2026
When a pinned item was pasted, the pasteboard-poll timer would
re-enter History.add(_:) with a duplicate item. The existing pinned
row is removed from `all` and its index is captured, then
limitHistorySize(to:) runs and can mutate `all` further by
deleting unpinned rows. The captured index becomes stale, and the
subsequent Array.insert(at:) can trap with "index out of range".

Crash signature:
  _assertionFailure
  Array._checkIndex
  Array.insert(_:at:)
  History.add(_:)            History.swift:181
  Clipboard.checkForChangesInPasteboard()
  __NSFireTimer

Fix: clamp the captured index to the current bounds of `all`
before inserting. Array.insert(at:) accepts 0...count inclusive,
so min(index, count) is always valid. When the original slot was
trimmed away, the re-pinned item now lands at the end of `all`;
in all other cases the behaviour is unchanged.

Adds a regression test that fills the unpinned history to capacity
and re-adds a duplicate of a pinned item with pinTo=.bottom, which
reliably reproduced the crash on the pre-fix code path.
@p3ngu1nx p3ngu1nx changed the title Fix popup rendering performance and screen bounds Fix popup rendering performance, screen bounds, and pinned-paste crash May 2, 2026
`Popup.handleKeyDown` only checked `event.keyCode` against the popup
shortcut, ignoring modifiers. When the popup hotkey is `⌘⇧V` (the
default), pressing plain "v" in the search field still satisfied
`isHotKeyCode == true` and, depending on popup state, routed to the
cycle/highlightNext branch instead of the search field. The "v"
keystroke was consumed and the selection moved down — making it
impossible to search for any term containing the letter V.

Add a `guard isHotKeyModifiers(event.modifierFlags)` after the
`pressedShortcutItem` block so non-hotkey-letter presses fall through
to the search field. Drop the now-redundant modifier check from the
`.toggle` branch.
@p3ngu1nx p3ngu1nx changed the title Fix popup rendering performance, screen bounds, and pinned-paste crash Fix popup rendering, bounds, pinned-paste crash, and search swallow May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot select with arrow keys after using mouse

2 participants