Skip to main content

max / audiofiles

Drag the row the press landed on, and say when the cooldown takes one Three things the flip lost, all per-row and all closable now that the renderer answers which row is under a point. The rows carry a Cells::value: the sample's own id. So row_at names the sample rather than a position, and the app looks it up in its own contents -- rather than matching a position back against the ordering the description happened to be built from, which is the same row identified twice and agreeing by luck. The position is not usable here anyway: selection indices carry the `..` entry's offset inside a directory and the description's do not. Pressing an unchosen row and dragging now drags that row, the way the shipped egui list did. The "unless it is already chosen" is the whole rule and it is what keeps a multi-sample drag working. And a drag the cooldown swallows says so, to the reader through a tooltip and to the log once per cooldown. Without them the gesture disappears silently, which reads as the app having missed it. Takes quasi 0.74.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 16:07 UTC
Signed with PGP, not checked
Commit: df23e3cc023e597c1e1e13a2a681ba4b9d0e141b
Parent: 5623af0
5 files changed, +218 insertions, -50 deletions
M Cargo.lock +34 -34
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.73.0"
4259 + version = "0.74.0"
4260 4260 dependencies = [
4261 4261 "docengine",
4262 4262 "egui",
@@ -4267,7 +4267,7 @@
4267 4267
4268 4268 [[package]]
4269 4269 name = "quasi-router"
4270 - version = "0.73.0"
4270 + version = "0.74.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -7556,38 +7556,6 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 - [[patch.unused]]
7560 - name = "quasi-type"
7561 - version = "0.1.1"
7562 -
7563 - [[patch.unused]]
7564 - name = "quasi-axum"
7565 - version = "0.73.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "quasi-basics"
7569 - version = "0.73.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-http"
7573 - version = "0.73.0"
7574 -
7575 - [[patch.unused]]
7576 - name = "quasi-notifs"
7577 - version = "0.73.0"
7578 -
7579 - [[patch.unused]]
7580 - name = "quasi-store"
7581 - version = "0.1.0"
7582 -
7583 - [[patch.unused]]
7584 - name = "quasi-tauri"
7585 - version = "0.73.0"
7586 -
7587 - [[patch.unused]]
7588 - name = "quasi-webview"
7589 - version = "0.73.0"
7590 -
7591 7559 [[patch.unused]]
7592 7560 name = "kberg"
7593 7561 version = "0.1.0"
@@ -7599,3 +7567,35 @@
7599 7567 [[patch.unused]]
7600 7568 name = "painhours"
7601 7569 version = "0.1.0"
7570 +
7571 + [[patch.unused]]
7572 + name = "quasi-type"
7573 + version = "0.1.1"
7574 +
7575 + [[patch.unused]]
7576 + name = "quasi-axum"
7577 + version = "0.74.0"
7578 +
7579 + [[patch.unused]]
7580 + name = "quasi-basics"
7581 + version = "0.74.0"
7582 +
7583 + [[patch.unused]]
7584 + name = "quasi-http"
7585 + version = "0.74.0"
7586 +
7587 + [[patch.unused]]
7588 + name = "quasi-notifs"
7589 + version = "0.74.0"
7590 +
7591 + [[patch.unused]]
7592 + name = "quasi-store"
7593 + version = "0.1.0"
7594 +
7595 + [[patch.unused]]
7596 + name = "quasi-tauri"
7597 + version = "0.74.0"
7598 +
7599 + [[patch.unused]]
7600 + name = "quasi-webview"
7601 + version = "0.74.0"
M Cargo.toml +2 -2
@@ -26,8 +26,8 @@
26 26 # The described screens, which are audiofiles-browser's screens. By git URL with
27 27 # a version requirement, per the tree's rule for cross-repo deps. They were
28 28 # behind a `quasi` feature until 2026-08-25, when the last flip landed.
29 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.73" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
31 31 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
32 32 egui_extras = { version = "0.35", default-features = false }
33 33 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -544,7 +544,18 @@
544 544 )])
545 545 });
546 546
547 - let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id)));
547 + // Named, so the renderer can answer which row the pointer is over with the
548 + // sample rather than with a position. `identified` and not `ticking`: this
549 + // list's selection is the app's -- `state.nav.selection` -- so there is no
550 + // tick to set, and the two were only ever written together because every
551 + // list that wanted an identity before this one also wanted a checkbox.
552 + //
553 + // What it closes: `panel::dragging_out` used to have to match `row_at`'s
554 + // index back against the sample ordering this description was built from,
555 + // which is the same row identified twice in two places, agreeing by luck.
556 + let mut row = Cells::new(values)
557 + .identified(sample.id.to_string())
558 + .activate(Action::post(format!("/files/{}/open", sample.id)));
548 559 row.current = current == Some(sample.id);
549 560 row.menu = menu(sample, collection, collections);
550 561 row
@@ -2416,15 +2416,13 @@
2416 2416 /// does, because where a row landed is the renderer's answer and a different
2417 2417 /// one per host.
2418 2418 ///
2419 - /// **What is still not the shipped list's behaviour**: this drags *what is
2420 - /// chosen* rather than the row under the cursor, where the shipped list
2421 - /// collapsed the selection onto that row first. Pressing a chosen row and
2422 - /// dragging is the ordinary case and is exact; pressing an unchosen one drags
2423 - /// the chosen set instead, which the drag image and the status line both name.
2424 - /// The rects make that closable now and the plumbing is this app's rather than
2425 - /// the vocabulary's -- the file list's rows carry no `Cells::value`, so the row
2426 - /// has to be matched back by index against the samples the description was
2427 - /// built from. Filed rather than guessed at here.
2419 + /// - **What the press chose.** Pressing an unchosen row and dragging drags
2420 + /// *that* row, the way the shipped list did: the selection collapses onto the
2421 + /// row under the cursor first. Closed 2026-08-28. The rows carry a
2422 + /// `Cells::value` now, so `row_at` answers with the sample itself and the app
2423 + /// looks it up in its own contents -- rather than matching a position back
2424 + /// against the ordering the description happened to be built from, which is
2425 + /// the same row identified twice and agreeing by luck.
2428 2426 fn dragging_out(ui: &egui::Ui, state: &mut BrowserState) -> bool {
2429 2427 let ctx = ui.ctx();
2430 2428 let (held, origin, latest) = ctx.input(|input| {
@@ -2436,6 +2434,7 @@
2436 2434 });
2437 2435
2438 2436 if cooling_down(state, held) {
2437 + swallowed(ui, state, held, origin, latest);
2439 2438 return false;
2440 2439 }
2441 2440 if !held || state.preview.instrument_visible {
@@ -2446,9 +2445,6 @@
2446 2445 if ctx.dragged_id().is_some() {
2447 2446 return false;
2448 2447 }
2449 - if state.nav.selection.count() == 0 {
2450 - return false;
2451 - }
2452 2448 let (Some(origin), Some(latest)) = (origin, latest) else {
2453 2449 return false;
2454 2450 };
@@ -2458,7 +2454,89 @@
2458 2454 // The press has to have landed on a row. Read off the renderer, which drew
2459 2455 // the table earlier this frame; the description says what is on the screen
2460 2456 // and never where, so this is the one side that can answer.
2461 - quasi_immediate::row_at(ui.ctx(), origin).is_some()
2457 + let Some(at) = quasi_immediate::row_at(ui.ctx(), origin) else {
2458 + return false;
2459 + };
2460 +
2461 + // What the press chose. Before the count check rather than after it: a press
2462 + // on an unchosen row with nothing chosen at all is still a press on a row,
2463 + // and the shipped list dragged it.
2464 + collapse_onto(state, at.value.as_deref());
2465 +
2466 + state.nav.selection.count() > 0
2467 + }
2468 +
2469 + /// Collapse the selection onto the row the press landed on, unless it is already
2470 + /// in it.
2471 + ///
2472 + /// "Unless" is the whole of the rule and it is what keeps a multi-sample drag
2473 + /// working: pressing one of several chosen rows means "drag all of these", and
2474 + /// pressing a row outside the set means "drag this one".
2475 + ///
2476 + /// The row is found by its value -- the sample's own id -- and looked up in
2477 + /// `nav.contents`, which is what `selection` indexes into. `row_at` also answers
2478 + /// with a position and that position is *not* usable here: it is the row's place
2479 + /// in the description drawn this frame, and the selection's indices carry the
2480 + /// `..` entry's offset when the browser is inside a directory.
2481 + fn collapse_onto(state: &mut BrowserState, value: Option<&str>) {
2482 + let Some(value) = value else {
2483 + return;
2484 + };
2485 + let offset = usize::from(state.nav.current_dir.is_some());
2486 + let Some(index) = state
2487 + .nav
2488 + .contents
2489 + .iter()
2490 + .position(|node| node.node.id.to_string() == value)
2491 + .map(|at| at + offset)
2492 + else {
2493 + return;
2494 + };
2495 + if state.nav.selection.selected.contains(&index) {
2496 + return;
2497 + }
2498 + state.nav.selection.set_single(index);
2499 + }
2500 +
2501 + /// Say that the cooldown took a drag, to the reader and to the log.
2502 + ///
2503 + /// Both were lost in the flip and both are per-row affordances the shipped list
2504 + /// had. Without them a reader who drags again too quickly gets nothing at all:
2505 + /// the gesture is swallowed silently, which reads as the app having missed it.
2506 + ///
2507 + /// The tooltip follows the pointer rather than sitting on a row, because a row
2508 + /// rect is the renderer's and this is the app's own furniture. The warning is
2509 + /// made once per cooldown -- the gesture is held for many frames, and a line per
2510 + /// frame is how a real signal becomes noise.
2511 + fn swallowed(
2512 + ui: &egui::Ui,
2513 + state: &mut BrowserState,
2514 + held: bool,
2515 + origin: Option<egui::Pos2>,
2516 + latest: Option<egui::Pos2>,
2517 + ) {
2518 + let (Some(origin), Some(latest)) = (origin, latest) else {
2519 + return;
2520 + };
2521 + // Only for a gesture that really was a drag. A click during the cooldown is
2522 + // not being swallowed -- clicks still work -- so saying so would be wrong.
2523 + if !held
2524 + || (latest - origin).length() <= DRAG_THRESHOLD
2525 + || quasi_immediate::row_at(ui.ctx(), origin).is_none()
2526 + {
2527 + return;
2528 + }
2529 + if !state.os_drag_cooldown_warned {
2530 + state.os_drag_cooldown_warned = true;
2531 + tracing::warn!("drag-out swallowed by the OS-drag cooldown");
2532 + }
2533 + egui::Tooltip::always_open(
2534 + ui.ctx().clone(),
2535 + ui.layer_id(),
2536 + egui::Id::new("os-drag-cooldown"),
2537 + egui::PopupAnchor::Pointer,
2538 + )
2539 + .show(|ui| ui.label("Just dragged. Ready again in a moment."));
2462 2540 }
2463 2541
2464 2542 /// Whether the last OS drag is still holding this one off.
@@ -2478,6 +2556,7 @@
2478 2556 };
2479 2557 if !held || since.elapsed() > DRAG_COOLDOWN {
2480 2558 state.os_drag_cooldown = None;
2559 + state.os_drag_cooldown_warned = false;
2481 2560 return false;
2482 2561 }
2483 2562 true
@@ -2862,6 +2941,72 @@
2862 2941 );
2863 2942 }
2864 2943
2944 + /// The other half of the drag that can be tested without a pointer: what a
2945 + /// press chooses. See [`collapse_onto`].
2946 + ///
2947 + /// Pressing an unchosen row and dragging drags that row, which is what the
2948 + /// shipped egui list did and what the described one lost.
2949 + #[test]
2950 + fn pressing_an_unchosen_row_collapses_the_selection_onto_it() {
2951 + let (mut state, _dir) = chosen();
2952 + // A second sample, so there is a row to press that is not the chosen
2953 + // one. `chosen()` leaves index 0 selected.
2954 + let vfs = state.current_vfs_id().unwrap();
2955 + let parent = state.nav.current_dir;
2956 + let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap();
2957 + db.conn()
2958 + .execute(
2959 + "INSERT OR IGNORE INTO samples \
2960 + (hash, original_name, file_extension, file_size, import_date, last_modified) \
2961 + VALUES ('bbb222', 'bbb222.wav', 'wav', 100, 0, 0)",
2962 + [],
2963 + )
2964 + .unwrap();
2965 + state
2966 + .backend
2967 + .create_sample_link(vfs, parent, "snare.wav", "bbb222")
2968 + .unwrap();
2969 + state.refresh_contents();
2970 + state.nav.selection.set_single(0);
2971 +
2972 + let other = state.nav.contents[1].node.id.to_string();
2973 + collapse_onto(&mut state, Some(&other));
2974 +
2975 + assert_eq!(
2976 + state.nav.selection.count(),
2977 + 1,
2978 + "one row is chosen, and it is the one pressed"
2979 + );
2980 + assert!(state.nav.selection.selected.contains(&1), "the pressed row");
2981 + }
2982 +
2983 + /// The "unless" that keeps a multi-sample drag working: pressing one of
2984 + /// several chosen rows means "drag all of these".
2985 + #[test]
2986 + fn pressing_a_row_that_is_already_chosen_leaves_the_set_alone() {
2987 + let (mut state, _dir) = chosen();
2988 + let pressed = state.nav.contents[0].node.id.to_string();
2989 +
2990 + collapse_onto(&mut state, Some(&pressed));
2991 +
2992 + assert_eq!(state.nav.selection.count(), 1);
2993 + assert!(state.nav.selection.selected.contains(&0));
2994 + }
2995 +
2996 + /// A row the renderer named and the contents no longer hold. The lookup
2997 + /// answers nothing rather than guessing at a position, which is the whole
2998 + /// reason the row is matched by value.
2999 + #[test]
3000 + fn a_row_the_contents_no_longer_hold_changes_nothing() {
3001 + let (mut state, _dir) = chosen();
3002 +
3003 + collapse_onto(&mut state, Some("not-a-node-id"));
3004 + assert!(state.nav.selection.selected.contains(&0), "unchanged");
3005 +
3006 + collapse_onto(&mut state, None);
3007 + assert!(state.nav.selection.selected.contains(&0), "unchanged");
3008 + }
3009 +
2865 3010 /// The stale-pointer half of the drag out, which is the half that can be
2866 3011 /// tested without a pointer. See [`cooling_down`].
2867 3012 #[test]
@@ -2881,6 +3026,10 @@
2881 3026 state.os_drag_cooldown.is_none(),
2882 3027 "and nothing is remembered"
2883 3028 );
3029 + assert!(
3030 + !state.os_drag_cooldown_warned,
3031 + "including whether this cooldown had said it swallowed one"
3032 + );
2884 3033 }
2885 3034
2886 3035 /// The case the timeout exists for: a drag that ended over another
@@ -250,6 +250,13 @@
250 250 /// Set when an OS drag fires; prevents re-triggering until the pointer is
251 251 /// genuinely released (egui sees button-up) or a safety timeout expires.
252 252 pub os_drag_cooldown: Option<Instant>,
253 + /// Whether the current cooldown has already said it swallowed a drag.
254 + ///
255 + /// The warning is worth making once per cooldown and not once per frame: a
256 + /// reader who keeps pulling holds the gesture for many frames, and a log
257 + /// line per frame is how a real signal becomes noise. Cleared with the
258 + /// cooldown itself.
259 + pub os_drag_cooldown_warned: bool,
253 260
254 261 // VFS mirror
255 262 pub mirror: MirrorUiState,
@@ -509,6 +516,7 @@
509 516 show_sync_intro: !sync_intro_dismissed,
510 517 },
511 518 os_drag_cooldown: None,
519 + os_drag_cooldown_warned: false,
512 520 mirror: MirrorUiState {
513 521 mirror_enabled,
514 522 mirror_path,