| 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 |
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 |
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 |
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 |
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 |
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 |
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
|