6.6 KiB
valw: drag the preview out (design)
Date: 2026-10-01
Status: approved in brainstorming, pending written-spec review
Builds on: 2026-09-30-valw-phase-2-preview-design.md (Phase 2, on main)
1. Context and goal
After a day of real use, the one thing missing from the preview was being able to grab a thumbnail and drop the screenshot into another app (a chat, a browser upload field, a file manager, an image editor) without looking for the file. The vision document lists this as "Dışarı sürükle" in 4.4.
Success: dragging a thumbnail out works with the user's everyday apps, and click → Satty and swipe → dismiss keep working exactly as before.
Out of scope
- A config switch for drag-and-drop (it is always on).
- Anything else from the post-test-day list (hover pause, notifications, right-click menu, the Phase 2 deferred minors).
2. Decisions
| Question | Decision | Why |
|---|---|---|
| Swipe vs drag-out | By direction: mostly rightward = swipe, anything else = drag-out | Thumbnails sit in the bottom-right corner, so drop targets are always left/up; nothing existing changes |
| What is carried | text/uri-list (the file) and image/png (the bytes) |
Each app takes what it understands |
| After a drag | Dropped → the thumbnail closes; cancelled → it comes back | Like macOS; a missed drop can be retried |
| What moves under the pointer | The thumbnail image as the drag icon; the original hides | You see what you carry |
| Action | Copy | The saved file stays where it is |
3. Gesture
A press on a thumbnail is classified once, as soon as the pointer has moved
at least CLICK_SLOP (4 logical px) from the press point, and never
changes afterwards:
- Swipe:
dx > 0and|dy| ≤ dx(rightward, at most 45° off horizontal). Behaviour unchanged from Phase 2. - Drag-out: any other direction.
- Click: released before the slop is reached. Behaviour unchanged (opens Satty).
stack::classify(dx, dy) -> Option<Gesture> (None below the slop) is a
pure function; the thumbnail remembers the first Some.
4. Drag flow
- On the move that classifies the press as a drag-out, the host creates a
wl_data_source(SCTKcreate_drag_and_drop_source, mime types from section 5, actionCopy) and callsstart_dragwith the thumbnail's surface as origin, a new icon surface, and the serial of the pointer press that started it. - The icon surface shows the thumbnail image at thumbnail size (buffer at
the output's scale, viewporter to logical size, 1 px border as before).
Its offset (
wl_surface.offset, wl_compositor v5+) keeps the point the user grabbed under the pointer. - The original thumbnail hides itself (transparent buffer, empty input region: the Phase 2 mechanism) for the duration of the drag. Only this thumbnail; the rest of the stack is unaffected. "Hidden for a capture" and "hidden for a drag" are separate flags, and a thumbnail is visible only when neither is set: a capture that ends mid-drag must not bring back the thumbnail being dragged.
- While dragging, the compositor owns the pointer. If the thumbnail's
timer fires, it is only recorded (
expired); the thumbnail doesn't close mid-drag. dnd_finished(dropped and accepted): the thumbnail closes at once (it is already invisible), and the stack restacks.cancelled(released on nothing, rejected, Esc): the icon goes away and the thumbnail reappears with the slide-in animation, or, if it expired during the drag, slides out right away.- The data source and icon surface are destroyed after either outcome.
The host binds wl_data_device_manager (SCTK DataDeviceManagerState)
and gets the seat's data device when the pointer capability appears. The
host never receives drops; SCTK's DataDeviceHandler and
DataOfferHandler get empty implementations.
A missing wl_data_device_manager (not the case on niri) disables
drag-out: the gesture falls back to snap-back, and a warning is logged
once.
5. Data
| Mime type | Written to the receiving app's pipe |
|---|---|
text/uri-list |
file_uri(path) followed by \r\n |
image/png |
the file's bytes, read from disk when requested |
file_uri(path): file:// plus the absolute path with every byte
percent-encoded except the RFC 3986 unreserved characters
(A-Z a-z 0-9 - . _ ~) and /. Spaces become %20, UTF-8 (e.g. Turkish
ı, ş) is encoded byte by byte, % and # are encoded.
Each send_request writes on its own short-lived thread, so a slow reader
or a large PNG can't block the host's event loop (and with it every
thumbnail). A failed read or write (file removed, reader gone) is logged;
the pipe is closed and the host keeps running.
6. Code layout
| File | Change |
|---|---|
src/stack.rs |
enum Gesture { Swipe, DragOut }, classify(dx, dy) |
src/dnd.rs (new) |
file_uri, MIME_TYPES, write_offer(mime, path, fd) (the thread body), the after-drag decision |
src/thumbnail.rs |
gesture state; drag icon surface; per-thumbnail hide/show around the drag |
src/host.rs |
data device, drag source, DataSourceHandler callbacks routed to the thumbnail |
src/wayland.rs |
DataDeviceManagerState in State, delegate impls |
7. Testing
7.1 Unit tests (cargo nextest)
stack::classify: under 4 px →None; right and 30° off right →Swipe; exactly 45° →Swipe; left, up, down, and steep right →DragOut.dnd::file_uri: plain path; spaces; Turkish characters;%and#;/kept.- After-drag decision: dropped → close; cancelled and not expired → come back; cancelled and expired → slide out.
dnd::write_offerthrough a real pipe:text/uri-listproduces the exact line;image/pngproduces the file bytes; a missing file is an error, not a panic.
7.2 Headless check
Unchanged: drag-and-drop needs synthetic pointer input. The existing checks guard click/swipe/hide against regressions.
7.3 Manual checks on niri
- A drag can be started from the thumbnail (layer surface) at all (verified while prototyping, before the plan).
- Drop into Dolphin → file copied; into a browser upload field → uploads; into a Discord message → attaches.
- Swipe right still dismisses; click still opens Satty.
- Drag left/up and release on nothing → the thumbnail comes back.
- A thumbnail whose timer fires during a drag slides out after the cancel.
- Drag onto the other monitor works.
8. Definition of done
cargo clippy -- -D warnings, cargo fmt --check, cargo nextest run,
nix flake check pass, the 7.3 checks are done on niri, and copland picks
it up with nix flake update valw.