Skip to main content

max / audiofiles

Restore the drag out of the file list as a host half `49b7429` replaced the shipped file list with `quasi::files` and took the five modules that drew it with it, so the drag that hands a selection to Finder or a DAW went with them: `start_os_drag` had no caller outside its own file and `crate::drag_out` was reachable only through it. For a sample manager that is close to the point of the app. Restored as the host half rather than as a description, which is the ruling on `edd4d1d8` and is what `quasi::files` already recorded: an OS drag that ends outside the window leaves egui's pointer state stale, and the cooldown that fixes that is a fact about this host's input rather than about a sample. So `panel::dragging_out` reads egui's raw pointer beside `locate` and `hand_over`, and the description says nothing. What the gesture can be told apart from is in its header. A described table hands the host no rows, so the guards are a drag-sense widget owning the gesture (a scrollbar, a splitter, a slider), the instrument panel, and the room the shell was drawn into. What it cannot tell apart is a press on the toolbar from a press on a row, so this drags what is chosen rather than collapsing the selection onto the row under the cursor first, and the per-row hover that said the cooldown was still running is gone with the rows. Both are recorded where the gesture is read. `start_os_drag` grows a no-op on platforms with no backend, so the call site needs no `cfg` and the gesture itself is type-checked everywhere.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 00:24 UTC
Signed with PGP, not checked
Commit: cb3e08dd9728472e6621cf3b2221e5c83b514ef0
Parent: fd57d44
4 files changed, +198 insertions, -18 deletions
M Cargo.lock +4 -4
@@ -7582,10 +7582,6 @@
7582 7582 name = "quasi-webview"
7583 7583 version = "0.62.0"
7584 7584
7585 - [[patch.unused]]
7586 - name = "quasi-type"
7587 - version = "0.1.0"
7588 -
7589 7585 [[patch.unused]]
7590 7586 name = "kberg"
7591 7587 version = "0.1.0"
@@ -7597,3 +7593,7 @@
7597 7593 [[patch.unused]]
7598 7594 name = "painhours"
7599 7595 version = "0.1.0"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-type"
7599 + version = "0.1.0"
@@ -31,10 +31,15 @@
31 31 //!
32 32 //! # What is deliberately not described
33 33 //!
34 - //! - **Drag and drop out of the app.** `draw_file_list` carries a macOS/Windows
35 - //! drag-cooldown state machine so an OS drag that ends outside the window does
36 - //! not leave egui's pointer state stale. That is a host input problem and
37 - //! nothing about it is a fact about a sample.
34 + //! - **Drag and drop out of the app.** A macOS/Windows drag out to Finder or a
35 + //! DAW carries a cooldown state machine, so an OS drag that ends outside the
36 + //! window does not leave egui's pointer state stale. That is a host input
37 + //! problem and nothing about it is a fact about a sample. It went missing for
38 + //! two days when this screen replaced the shipped list and the host half went
39 + //! with it; `panel::dragging_out` is that half, restored 2026-08-25, and it
40 + //! reads egui's raw pointer rather than anything said here. The cost of
41 + //! saying nothing is recorded there: the gesture cannot name the row it
42 + //! started on, so it drags what is chosen.
38 43 //! - **The waveform and the inline rename.** Each is its own affordance;
39 44 //! folding them in here would make the port about size rather than about
40 45 //! shape.
@@ -20,14 +20,18 @@
20 20 //! # What this module is, and what it deliberately is not
21 21 //!
22 22 //! It is the *host* half, and it is small on purpose: plumbing against described
23 - //! screens that know nothing about egui. Everything it does is one of four
23 + //! screens that know nothing about egui. Everything it does is one of five
24 24 //! things, and none of them is drawing:
25 25 //!
26 26 //! 1. resolve the host facts the screens need (themes, the palette) and adapt
27 27 //! the app's own handles to the narrow traits the screens borrow,
28 28 //! 2. hold each [`Runtime`] across frames, because a frame does not outlive itself,
29 29 //! 3. hand a [`Step`] to the router and the answer back to the runtime,
30 - //! 4. put a route failure somewhere the user can see it.
30 + //! 4. put a route failure somewhere the user can see it,
31 + //! 5. perform what a description asked the host for and cannot say itself: a
32 + //! file picker ([`locate`]), a place to save ([`hand_over`]), the way back
33 + //! ([`leave`]), and the one gesture that is not an outcome at all
34 + //! ([`dragging_out`], the drag into a DAW).
31 35 //!
32 36 //! There is no `if let Node::...` anywhere here, and there should never be one.
33 37 //! The moment this file starts deciding what a node looks like, the drawing has
@@ -187,6 +191,12 @@
187 191 inline(ui, &mut runtime, &host, "/", true);
188 192 state.described.shell = runtime;
189 193 apply(ui.ctx(), state, sync, intents.into_inner());
194 +
195 + // The one gesture the description does not carry, after the intents rather
196 + // than before them: what a press chose is what a drag should carry out.
197 + if dragging_out(ui, state) {
198 + crate::ui::file_list_menus::start_os_drag(state);
199 + }
190 200 }
191 201
192 202 /// Draw the described sample editor, and act on whatever was pressed.
@@ -2408,6 +2418,115 @@
2408 2418 });
2409 2419 }
2410 2420
2421 + /// How far the pointer travels before a press over the list counts as a drag.
2422 + ///
2423 + /// The shipped list's number, kept: four points is far enough that a press with
2424 + /// a shaky hand still opens the row it landed on.
2425 + const DRAG_THRESHOLD: f32 = 4.0;
2426 +
2427 + /// How long the drag cooldown holds when egui never sees the button released.
2428 + ///
2429 + /// The safety net rather than the mechanism: an OS drag that ends over another
2430 + /// application swallows the mouse-up, and the pointer reads as held forever. Two
2431 + /// seconds is the shipped list's number.
2432 + const DRAG_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
2433 +
2434 + /// Drag what is chosen out of the file list, which is the host's half of it.
2435 + ///
2436 + /// The host half of a *gesture*, where [`locate`] and [`hand_over`] are host
2437 + /// halves of an outcome, and it is here for the reason `quasi::files` records in
2438 + /// its header: an OS drag that ends outside the window leaves egui's pointer
2439 + /// state stale, and the cooldown that fixes that is a fact about this host's
2440 + /// input rather than about a sample. So the renderer reaches for
2441 + /// [`start_os_drag`](crate::ui::file_list_menus::start_os_drag) directly and the
2442 + /// description says nothing.
2443 + ///
2444 + /// It went missing in `49b7429`, which replaced the shipped list with the
2445 + /// described one and took the five modules that drew it with it. Dragging a
2446 + /// sample into a DAW is close to the point of a sample manager, so a flip that
2447 + /// loses it has failed the same way the tag-undo finding said a flip that loses
2448 + /// Cmd+Z has. Restored 2026-08-25, ruled by Max on audiofiles `edd4d1d8`.
2449 + ///
2450 + /// # What the gesture can be told apart from, and what it cannot
2451 + ///
2452 + /// A described table hands the host no rows. `quasi-immediate` senses each cell
2453 + /// for a click and a menu, and neither the cell's rect nor its index reaches
2454 + /// this side, so what is read here is egui's raw pointer plus the room the shell
2455 + /// was given. Three guards do the telling apart:
2456 + ///
2457 + /// - **A drag-sense widget owning the gesture.** A scrollbar, a panel splitter,
2458 + /// a slider and a text selection are all `Sense::drag`, so `dragged_id` is set
2459 + /// and this stands aside. That is the whole of the dangerous set: everything
2460 + /// else under the pointer is a click widget.
2461 + /// - **The instrument panel.** A sample dragged while it is open is being
2462 + /// assigned to a zone, which is egui's own drag-and-drop and not the OS's.
2463 + /// The shipped list made the same call.
2464 + /// - **The room.** The press has to have landed inside the pane the shell was
2465 + /// drawn into, which leaves out the filter panel, the detail panel and every
2466 + /// floating window.
2467 + ///
2468 + /// **What it cannot tell apart is a press on the toolbar from a press on a
2469 + /// row**, and what follows from that is the one behaviour that is not the
2470 + /// shipped list's: this drags *what is chosen* rather than the row under the
2471 + /// cursor, where the shipped list collapsed the selection onto that row first.
2472 + /// Pressing a chosen row and dragging is the ordinary case and is exact;
2473 + /// pressing an unchosen one drags the chosen set instead, which the drag image
2474 + /// and the status line both name. The per-row hover that said "Just dragged.
2475 + /// Ready again in a moment." is gone with the rows for the same reason. Closing
2476 + /// either wants the description to say where its rows are, which is a question
2477 + /// for the vocabulary rather than something to guess at here.
2478 + fn dragging_out(ui: &egui::Ui, state: &mut BrowserState) -> bool {
2479 + let ctx = ui.ctx();
2480 + let (held, origin, latest) = ctx.input(|input| {
2481 + (
2482 + input.pointer.button_down(egui::PointerButton::Primary),
2483 + input.pointer.press_origin(),
2484 + input.pointer.latest_pos(),
2485 + )
2486 + });
2487 +
2488 + if cooling_down(state, held) {
2489 + return false;
2490 + }
2491 + if !held || state.preview.instrument_visible {
2492 + return false;
2493 + }
2494 + // Something that really is a drag has the gesture: a scrollbar, a splitter,
2495 + // a slider, a caret sweeping a text field.
2496 + if ctx.dragged_id().is_some() {
2497 + return false;
2498 + }
2499 + if state.nav.selection.count() == 0 {
2500 + return false;
2501 + }
2502 + let (Some(origin), Some(latest)) = (origin, latest) else {
2503 + return false;
2504 + };
2505 + (latest - origin).length() > DRAG_THRESHOLD && ui.max_rect().contains(origin)
2506 + }
2507 +
2508 + /// Whether the last OS drag is still holding this one off.
2509 + ///
2510 + /// After a drag that ends over another application, egui never sees the button
2511 + /// come up: the pointer reads as held down and the next frame would start a
2512 + /// second drag from the same press. So a drag sets [`BrowserState::os_drag_cooldown`]
2513 + /// and this clears it on the first frame where the pointer is genuinely up, or
2514 + /// when [`DRAG_COOLDOWN`] says the button-up is never coming.
2515 + ///
2516 + /// Separate from [`dragging_out`] because it is the whole of the stale-pointer
2517 + /// problem and is the one part of the gesture that can be tested without a
2518 + /// pointer.
2519 + fn cooling_down(state: &mut BrowserState, held: bool) -> bool {
2520 + let Some(since) = state.os_drag_cooldown else {
2521 + return false;
2522 + };
2523 + if !held || since.elapsed() > DRAG_COOLDOWN {
2524 + state.os_drag_cooldown = None;
2525 + return false;
2526 + }
2527 + true
2528 + }
2529 +
2411 2530 /// Ask the host where something goes, and write the answer down for later.
2412 2531 ///
2413 2532 /// The host half of `Outcome::Locate`, which quasi ruled and shipped in 0.60
@@ -2746,4 +2865,40 @@
2746 2865 state.detail.selected_tags
2747 2866 );
2748 2867 }
2868 +
2869 + /// The stale-pointer half of the drag out, which is the half that can be
2870 + /// tested without a pointer. See [`cooling_down`].
2871 + #[test]
2872 + fn the_drag_cooldown_ends_when_the_pointer_comes_up() {
2873 + let (mut state, _dir) = chosen();
2874 + state.os_drag_cooldown = Some(std::time::Instant::now());
2875 +
2876 + assert!(
2877 + cooling_down(&mut state, true),
2878 + "still held, so the next frame must not start a second drag"
2879 + );
2880 + assert!(
2881 + !cooling_down(&mut state, false),
2882 + "let go, so the gesture is available again"
2883 + );
2884 + assert!(
2885 + state.os_drag_cooldown.is_none(),
2886 + "and nothing is remembered"
2887 + );
2888 + }
2889 +
2890 + /// The case the timeout exists for: a drag that ended over another
2891 + /// application, whose mouse-up egui never saw.
2892 + #[test]
2893 + fn the_drag_cooldown_expires_when_no_button_up_arrives() {
2894 + let (mut state, _dir) = chosen();
2895 + state.os_drag_cooldown = std::time::Instant::now().checked_sub(DRAG_COOLDOWN * 2);
2896 + assert!(state.os_drag_cooldown.is_some(), "the clock went back");
2897 +
2898 + assert!(
2899 + !cooling_down(&mut state, true),
2900 + "the pointer still reads as held and the wait is over anyway"
2901 + );
2902 + assert!(state.os_drag_cooldown.is_none());
2903 + }
2749 2904 }
@@ -1,18 +1,22 @@
1 1 //! What is left of the file list's context menus, plus the drag-out handler.
2 2 //!
3 - //! **Three of the four things in here have no caller**, and that is a regression
3 + //! **Two of the four things in here have no caller**, and that is a regression
4 4 //! rather than dead code: the file-list flip (`49b7429`) replaced the shipped
5 5 //! list with `quasi::files`, which describes the *row* menu through
6 6 //! [`Cells::menu`](quasi_router::Cells) and does not describe the other two
7 - //! menus or the drag at all. So the selection menu, the empty-space menu and
8 - //! dragging a sample out to a DAW are all unreachable in the running app.
7 + //! menus. So the selection menu and the empty-space menu are both unreachable in
8 + //! the running app.
9 9 //!
10 10 //! They are kept rather than deleted, because deleting them is losing the
11 - //! features quietly and each is waiting on something specific:
12 - //! `quasi:vocabulary:anchored-menu` for the two menus (a menu over a selection
13 - //! or over a surface has no container in the vocabulary), and a host-input
14 - //! answer for the drag, which `quasi::files` records as deliberately undescribed.
15 - //! Filed as audiofiles `edd4d1d8`.
11 + //! features quietly, and what they wait on is `quasi:vocabulary:anchored-menu`:
12 + //! a menu over a selection and a menu over a surface have no container in the
13 + //! vocabulary. Filed as audiofiles `41996627`.
14 + //!
15 + //! [`start_os_drag`] was the third and has a caller again as of 2026-08-25.
16 + //! Nothing about it was ever going to be described. `quasi::files` records the
17 + //! drag as deliberately undescribed and the reason holds, so what it was missing
18 + //! was a host half, and `quasi::panel::dragging_out` is now that half.
19 + //! Ruled by Max on audiofiles `edd4d1d8`.
16 20 //!
17 21 //! `draw_context_menu` was the fourth and is deleted (2026-08-25): its
18 22 //! replacement ships, so it was the one thing in here that really was dead.
@@ -341,6 +345,12 @@
341 345 }
342 346 }
343 347
348 + /// Hand what is chosen to the operating system as a drag.
349 + ///
350 + /// Called by `quasi::panel::dragging_out`, which is where the gesture is read.
351 + /// Directories and samples whose bytes are still in the cloud are dropped: there
352 + /// is nothing on disk to hand over, and a drag carrying fewer files than the
353 + /// reader chose is the honest answer rather than a refusal.
344 354 #[cfg(any(target_os = "macos", target_os = "windows"))]
345 355 pub fn start_os_drag(state: &mut BrowserState) {
346 356 let nodes = state.selected_nodes();
@@ -371,6 +381,16 @@
371 381 }
372 382 }
373 383
384 + /// No OS drag backend on this platform, so the gesture answers with nothing.
385 + ///
386 + /// `drag_out::begin_drag` does compile here and refuses, but it lays out a temp
387 + /// directory of friendly-named links before it does. The gesture that calls this
388 + /// is read once per frame of a held pointer, so the answer stops here rather
389 + /// than paying for that while nothing can come of it. Linux gets a drag out of
390 + /// the library when `drag_out` grows a backend for it, not before.
391 + #[cfg(not(any(target_os = "macos", target_os = "windows")))]
392 + pub fn start_os_drag(_state: &mut BrowserState) {}
393 +
374 394 #[cfg(test)]
375 395 mod tests {
376 396 use super::*;