Skip to content

Drag a clipboard entry out into another app - #596

Merged
abue-ammar merged 6 commits into
abue-ammar:mainfrom
electro56435:clipboard-drag-out
Sep 11, 2026
Merged

Drag a clipboard entry out into another app#596
abue-ammar merged 6 commits into
abue-ammar:mainfrom
electro56435:clipboard-drag-out

Conversation

@electro56435

Copy link
Copy Markdown
Contributor

Related issue

Closes #544

What changed

A clipboard row is now a drag source. Press, move four points, and the entry goes to whatever is under the cursor: an image or file as a file URL, a link as public.url plus its string, plain text as a string.

The one non-obvious thing is copy versus move. imagesDir sits in Application Support on the boot volume, which is the same volume as almost every drop target, so a file-URL drop there defaults to a move. That carries the blob out of the history and leaves the row pointing at nothing. Only NSDraggingSource can answer sourceOperationMaskFor with .copy, and SwiftUI's onDrag takes an NSItemProvider and nothing else. So ClipDragHandle is an AppKit overlay that tracks the gesture itself, the same shape as WindowDragHandle.

The overlay claims the left button outright, so it answers the click and the double click too. The row's onTapGesture and its double-tap simultaneousGesture are deleted rather than left underneath, where they would never fire.

It reuses what is there rather than adding vocabulary:

  • textForm still decides what counts as a link, so the drag and the type filter cannot disagree.
  • QuicklinkDestination.detect builds the URL.
  • ClipboardCoordinator.dragPayload(for:) stats the file first, so a vanished one raises the HUD instead of handing another app a dead path.
  • The preview is drawn, not snapshotted. SwiftUI renders into layers, so cacheDisplay on the row returns a transparent bitmap. A file uses the thumbnail already cached at 64 px, a link or text gets a drawn tile.

One commit is unrelated to the feature. ClipboardStore.search had a bare prefix(_:) inside a + chain, which the Command Line Tools Swift 6.2.1 type checker resolves to the Sequence overload and then fails the whole expression. Spelling out Array(...) fixes it and changes no behavior. Without it four harnesses cannot compile outside Xcode.

Memory footprint

Not measured. There is no Xcode on the machine this was written on, so there is no Instruments run and no leak test.

What the code does allocate: one NSView per visible row, and one NSImage per drag that lives for the length of the gesture. The file preview reads the thumbnail cache only and never decodes on mouse-down. Nothing is retained after draggingSession(_:endedAt:operation:) returns.

Step Memory
Idle after launch
Palette open
Feature in use (peak)
Palette closed, settled

Leak-tested: no

Demo

Not recorded yet.

Drawbacks

  • The overlay owns the left button for the whole row. Any future left-click gesture on a clipboard row has to go through clipDraggable instead of a SwiftUI gesture.
  • The drag preview is drawn rather than a copy of the row, so it does not match the row pixel for pixel.
  • A drop that lands hides the palette, the same as a paste. A cancelled drag leaves it up.
  • .copy is returned for every context, including inside the app. There is no in-app drop target today.

Tests & validation

Scripts/run-tests.sh, all 68 harnesses pass. Three of them glob Clipboard/Model/*.swift and now compile ClipDragPayload.swift, so the script passes QuicklinkDestination.swift alongside them. No cases were added.

SwiftLint and the purity grep are clean. docs/features/clipboard.md has a "Dragging out" section.

By hand, against a local build: dragging an image into Finder and into a browser. Links, text, and the drawn previews are not yet hand-tested.

@github-actions

Copy link
Copy Markdown

Closing this automatically — it doesn't link an approved issue.

Tinycast requires the bug or feature to be agreed before code is written, so effort isn't
wasted on something that won't be merged. See
CONTRIBUTING.md.

To get this merged:

  1. Open an issue (or find the existing one) describing the change.
  2. Wait for it to be marked approved.
  3. Put Closes #<number> in this PR's description and reopen it.

Docs-only changes (*.md, docs/, website/) skip this check. Nothing personal — this is
automated.

@github-actions github-actions Bot closed this Sep 11, 2026
@abue-ammar abue-ammar reopened this Sep 11, 2026
electro56435 and others added 6 commits September 12, 2026 02:40
Left open, the type checker reads .prefix as the Sequence overload, types
the result as PrefixSequence and stops resolving the whole + chain. The
Swift 6.2.1 toolchain in the Command Line Tools rejects the line outright,
which takes clipboard-test, clipboard-search-test and every other harness
that compiles the store down with it. Behaviour is unchanged.
An image or file row becomes a drag source for the file it already is, so
reaching another app costs one gesture instead of Reveal in Finder plus a
second drag.

The drag is AppKit rather than SwiftUI's onDrag for one reason: imagesDir
sits on the boot volume, where a file-URL drop defaults to a move, and a
move carries the blob out of the history and strands its row. Only an
NSDraggingSource can answer sourceOperationMaskFor, so ClipDragView
answers .copy everywhere. The handle claims mouse-down the way
WindowDragHandle does, since the hosting view eats the click first, and
forwards anything under 4pt of slop as the click it was.

Text rows are untouched: dragURL is nil for them, so no overlay is
installed and their gestures stay as they were.
A text row had no payload and so no overlay, which left every entry that
is not an image or a file undraggable. dragPayload now answers for all
three: the file URL for anything on disk, and for a text row the
classification the store already derives.

A link writes two pasteboard types from one item. A browser reads
public.url, a text field reads the string, and neither has to settle for
the other's flavour. A bare domain gets https:// so the URL is one a
browser accepts, which is the same assumption the address bar makes.

The drag preview is now the row as drawn, since a text row has no
thumbnail to fall back on.
Four things the review caught.

The row snapshot never drew anything. SwiftUI renders into layers, so
cacheDisplay hands back a transparent bitmap and the drag carried no
image at all. Previews are drawn per payload now: the cached tile for a
file, a rounded text tile for a link or a copy. The dragging frame is
sized to that image and centred on the cursor, and a refused drop
animates back, so a drag that achieved nothing says so.

dragPayload touched no store state and pushed ClipboardStore past 1000
lines. It is a computed property on ClipboardItem now, in its own Model
file beside the ClipDragPayload it returns, which is where textForm and
colorValue already live.

The bespoke bare-domain helper is gone. QuicklinkDestination.detect
already parses schemes, network shares and deeplinks. textForm stays the
one answer to whether an entry is a link, so the drag and the type filter
cannot disagree; the detector only builds the URL.

A vanished file is reported rather than dragged out as a dead path, which
is what Reveal and Open already do. The payload resolves on mouse-down
instead of on every row render, so the stat costs one call per drag.

Also deletes the row's onTapGesture and double-tap gesture. The overlay
claims mouse-down on every row, so both were unreachable duplicates of
the closures the handle already calls.
The section still described dragURL on the store, a nil payload for text
and the onTapGesture the overlay replaced, all three of which went in the
last two commits. It now covers what is there: dragPayload on the item,
the mouse-down resolution and its stat, the QuicklinkDestination reuse,
and why previews are drawn rather than snapshotted.
Three things from the review.

The drag overlay sat above the actions catcher and answered every event
it was offered, right-mouse included. NSView forwards an unhandled
rightMouseDown up the superview chain, never to a sibling, so the catcher
underneath was unreachable and the row's actions menu silently stopped
opening. ClipDragView now declines right events in hitTest, the mirror of
what RightClickCatcher already does with the left button, so neither
overlay can claim what the other needs. Verified against a real event
loop: the right click lands on the catcher and the left one still starts
the drag.

lockFocus and unlockFocus are deprecated, and the SDK names
NSImage(size:flipped:drawingHandler:) as the replacement. The text tile
draws through that instead.

The comments were stacked two and three lines deep. Each is one line now,
and the ordering invariant the overlay depends on is written down in the
feature doc rather than argued in the file.
@abue-ammar
abue-ammar merged commit 7964991 into abue-ammar:main Sep 11, 2026
1 check passed
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.

[Feature]: drag and drop from clipboard history

2 participants