From 328b3f058ec2a2c765fb60ffd23ed92ab2bbd174 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Thu, 1 Oct 2026 00:55:30 +0300 Subject: [PATCH] docs: preview drag-out design spec --- ...2026-10-01-valw-preview-drag-out-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md diff --git a/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md b/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md new file mode 100644 index 0000000..f1bd757 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md @@ -0,0 +1,146 @@ +# 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 > 0` and `|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` (`None` below the slop) is a +pure function; the thumbnail remembers the first `Some`. + +## 4. Drag flow + +1. On the move that classifies the press as a drag-out, the host creates a + `wl_data_source` (SCTK `create_drag_and_drop_source`, mime types from + section 5, action `Copy`) and calls `start_drag` with the thumbnail's + surface as origin, a new icon surface, and the serial of the pointer + press that started it. +2. 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. +3. 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. +4. 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. +5. `dnd_finished` (dropped and accepted): the thumbnail closes at once (it + is already invisible), and the stack restacks. +6. `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. +7. 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_offer` through a real pipe: `text/uri-list` produces the + exact line; `image/png` produces 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`.